Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cef1689c05 | ||
|
|
3cca956ee7 | ||
|
|
818af137f3 | ||
|
|
338bed7eb7 | ||
|
|
67d3c13bb6 | ||
|
|
e4c240009d | ||
|
|
6553c0227d | ||
|
|
4fade1a9d3 | ||
|
|
3f1e06479d | ||
|
|
caeb9269d9 | ||
|
|
70ef42c311 | ||
|
|
1e972fe7dc | ||
|
|
700048337f | ||
|
|
3221ccfd29 | ||
|
|
0d6b08f5d2 | ||
|
|
92aea89b74 | ||
|
|
f59b3d529b | ||
|
|
c892d9dcc8 | ||
|
|
5e8af39563 | ||
|
|
7c1c1b61cc | ||
|
|
6eefeafcbf | ||
|
|
e0c7399998 | ||
|
|
4d0dc2c2ce | ||
|
|
e9494dd4b3 | ||
|
|
33de91cc86 | ||
|
|
f7872b0980 | ||
|
|
2bda603647 | ||
|
|
1ab4d01a3a | ||
|
|
6183bcd421 | ||
|
|
1617dc5442 | ||
|
|
6e0863449f | ||
|
|
1fb984e352 | ||
|
|
ce0ea66994 | ||
|
|
4fd2c7ce7d | ||
|
|
2ff7345a73 | ||
|
|
1de04d4908 | ||
|
|
153f9a7cef | ||
|
|
0a7201d754 | ||
|
|
12ffd217bb | ||
|
|
a5b0655623 | ||
|
|
fa5f347c08 | ||
|
|
1d03800ab2 | ||
|
|
9d982ab73f | ||
|
|
f57b472211 | ||
|
|
d81fdf4104 | ||
|
|
d46f8ffd77 | ||
|
|
5cf5aa9591 |
+56
-37
@@ -1,60 +1,79 @@
|
||||
# Tinker Tickets Environment Configuration
|
||||
# Copy this file to .env and fill in your values
|
||||
#
|
||||
# NOTE: This file is parsed with parse_ini_file(). Any value containing special
|
||||
# characters (#, ;, =, quotes, spaces, etc.) MUST be wrapped in double quotes,
|
||||
# e.g. DB_PASS="p@ss;word#1". The application now fails loudly (dies with a clear
|
||||
# error) if the .env file cannot be parsed, so an unquoted special character will
|
||||
# take the whole app down rather than silently using a wrong value.
|
||||
; Tinker Tickets Environment Configuration
|
||||
; Copy this file to .env and fill in your values
|
||||
;
|
||||
; NOTE: This file is parsed with PHP's parse_ini_file. Any value containing
|
||||
; special characters -- #, ;, =, quotes, spaces, etc. -- MUST be wrapped in
|
||||
; double quotes, e.g. DB_PASS="p@ss;word#1". The application now fails loudly
|
||||
; -- dies with a clear error -- if the .env file cannot be parsed, so an
|
||||
; unquoted special character will take the whole app down rather than
|
||||
; silently using a wrong value.
|
||||
;
|
||||
; Comments in this file use ";" rather than "#": PHP's ini parser treats "#"
|
||||
; comments as fragile -- punctuation like parentheses or quotes inside a "#"
|
||||
; comment can produce a syntax error even though the line is meant to be
|
||||
; inert, silently breaking every value below it. ";" comments don't have this
|
||||
; problem, so keep using ";" for any comment added to this file.
|
||||
|
||||
# Database Configuration
|
||||
; Database Configuration
|
||||
DB_HOST=10.10.10.50
|
||||
DB_USER=tinkertickets
|
||||
DB_PASS=your_password_here
|
||||
DB_NAME=ticketing_system
|
||||
|
||||
# Matrix Webhook (optional - for notifications via matrix-hookshot)
|
||||
# Set to your hookshot generic webhook URL, e.g.:
|
||||
# https://matrix.lotusguild.org/webhook/<uuid>
|
||||
; Matrix Webhook (optional - for notifications via matrix-hookshot)
|
||||
; Set to your hookshot generic webhook URL, e.g.:
|
||||
; https://matrix.lotusguild.org/webhook/uuid-goes-here
|
||||
MATRIX_WEBHOOK_URL=
|
||||
|
||||
# Matrix users to @mention on every new ticket (comma-separated Matrix user IDs)
|
||||
# e.g. @jared:matrix.lotusguild.org,@alice:matrix.lotusguild.org
|
||||
; Matrix users to @mention on every new ticket (comma-separated Matrix user IDs)
|
||||
; e.g. @jared:matrix.lotusguild.org,@alice:matrix.lotusguild.org
|
||||
MATRIX_NOTIFY_USERS=
|
||||
|
||||
# Application Domain (required for Matrix webhook ticket links)
|
||||
# Set this to your public domain (e.g., t.lotusguild.org)
|
||||
; Matrix homeserver domain (used to build Matrix user IDs from LLDAP usernames)
|
||||
MATRIX_DOMAIN=
|
||||
|
||||
; Synapse internal URL and admin token (used to resolve usernames -> Matrix IDs
|
||||
; for watcher DMs)
|
||||
SYNAPSE_ADMIN_URL=
|
||||
SYNAPSE_ADMIN_TOKEN=
|
||||
|
||||
; Optional: send a Matrix notification on comments and/or assignments (0/1)
|
||||
MATRIX_NOTIFY_COMMENTS=0
|
||||
MATRIX_NOTIFY_ASSIGNMENTS=0
|
||||
|
||||
; Application Domain (required for Matrix webhook ticket links)
|
||||
; Set this to your public domain, e.g. t.lotusguild.org
|
||||
APP_DOMAIN=
|
||||
|
||||
# Allowed Hosts for HTTP_HOST validation (comma-separated)
|
||||
# Include all domains that can access this application
|
||||
; Allowed Hosts for HTTP_HOST validation (comma-separated)
|
||||
; Include all domains that can access this application
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
# Trusted reverse proxy IP(s), comma-separated (e.g. the Authelia/nginx proxy).
|
||||
# Set this to the IP address(es) of your reverse proxy. Authelia forward-auth
|
||||
# headers (Remote-User / Remote-Groups) and forwarded client IPs are only
|
||||
# trusted when REMOTE_ADDR is in this list.
|
||||
#
|
||||
# Leaving this EMPTY disables reverse-proxy verification entirely: the app then
|
||||
# trusts Remote-User / Remote-Groups headers from ANY source. That is unsafe if
|
||||
# the PHP backend is reachable directly (bypassing the proxy), because a client
|
||||
# can then spoof those headers and log in as an admin. Only leave it empty when
|
||||
# network topology guarantees PHP is reachable solely via the trusted proxy.
|
||||
#
|
||||
# Exact IP match only (no CIDR). Example (single proxy): TRUSTED_PROXIES=10.10.10.27
|
||||
# Example (multiple): TRUSTED_PROXIES=10.10.10.27,10.10.10.28
|
||||
; Trusted reverse proxy IPs, comma-separated -- e.g. the Authelia/nginx proxy.
|
||||
; Set this to the IP address(es) of your reverse proxy. Authelia forward-auth
|
||||
; headers (Remote-User / Remote-Groups) and forwarded client IPs are only
|
||||
; trusted when REMOTE_ADDR is in this list.
|
||||
;
|
||||
; Leaving this EMPTY disables reverse-proxy verification entirely: the app then
|
||||
; trusts Remote-User / Remote-Groups headers from ANY source. That is unsafe if
|
||||
; the PHP backend is reachable directly (bypassing the proxy), because a client
|
||||
; can then spoof those headers and log in as an admin. Only leave it empty when
|
||||
; network topology guarantees PHP is reachable solely via the trusted proxy.
|
||||
;
|
||||
; Exact IP match only (no CIDR). Example (single proxy): TRUSTED_PROXIES=10.10.10.27
|
||||
; Example (multiple): TRUSTED_PROXIES=10.10.10.27,10.10.10.28
|
||||
TRUSTED_PROXIES=
|
||||
|
||||
# Timezone (default: America/New_York)
|
||||
; Timezone (default: America/New_York)
|
||||
TIMEZONE=America/New_York
|
||||
|
||||
# LDAP / lldap (for user avatar lookups)
|
||||
; LDAP / lldap (for user avatar lookups)
|
||||
LDAP_ENABLED=true
|
||||
LDAP_HOST=10.10.10.39
|
||||
LDAP_PORT=3890
|
||||
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_BASE_DN=dc=example,dc=com
|
||||
LDAP_USER_BASE=ou=people,dc=example,dc=com
|
||||
# How long to cache avatar images locally (seconds, default 3600)
|
||||
LDAP_BASE_DN="dc=example,dc=com"
|
||||
LDAP_USER_BASE="ou=people,dc=example,dc=com"
|
||||
; How long to cache avatar images locally (seconds, default 3600)
|
||||
AVATAR_CACHE_TTL=3600
|
||||
|
||||
@@ -94,11 +94,22 @@ The following features are intentionally **not planned** for this system:
|
||||
- **Required Fields**: Mark fields as required for validation
|
||||
|
||||
### API Key Management
|
||||
- **Admin UI**: Generate and manage API keys at `/admin/api-keys`
|
||||
- **Admin UI**: Generate and manage API keys at `/admin/api-keys` (paginated)
|
||||
- **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`.
|
||||
- **Expiration**: Optional expiration dates for keys
|
||||
- **Revocation**: Revoke compromised keys instantly
|
||||
|
||||
### 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).
|
||||
|
||||
| Endpoint | Method | Scope | Purpose |
|
||||
|----------|--------|-------|---------|
|
||||
| `/create_ticket_api.php` | POST | read_write | Create a ticket (hwmonDaemon, external tools) |
|
||||
| `/api/tickets_api.php` | GET | read | List/triage the queue (`?status=`, `?priority=`, `?host=` [title match], `?page=`, `?limit=`) **or** read one (`?ticket_id=NNN`) with its comments |
|
||||
| `/api/ticket_comment_api.php` | POST | read_write | Add a comment: `{ticket_id, comment_text, markdown_enabled?}` |
|
||||
| `/api/ticket_status_api.php` | POST | read_write | Change/close status (workflow-validated): `{ticket_id, status, comment?}` — `comment` is required for transitions that require one (e.g. → Closed); it is posted as the close reason in the same call |
|
||||
|
||||
### User Management & Authentication
|
||||
- **SSO Integration**: Authelia authentication with LLDAP backend
|
||||
- **Role-Based Access**: Admin and standard user roles
|
||||
@@ -250,6 +261,9 @@ Content-Type: application/json
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/create_ticket_api.php` | POST | Create ticket via API key (hwmonDaemon, external tools) |
|
||||
| `/api/tickets_api.php` | GET | Bearer: list/triage queue or read one ticket + comments |
|
||||
| `/api/ticket_comment_api.php` | POST | Bearer: add a comment (read_write scope) |
|
||||
| `/api/ticket_status_api.php` | POST | Bearer: change/close status, workflow-validated (read_write scope) |
|
||||
| `/api/update_ticket.php` | POST | Update ticket with workflow validation |
|
||||
| `/api/assign_ticket.php` | POST | Assign ticket to user |
|
||||
| `/api/add_comment.php` | POST | Add comment to ticket |
|
||||
@@ -348,7 +362,6 @@ tinker_tickets/
|
||||
│ ├── Database.php # Centralized mysqli connection
|
||||
│ ├── ErrorHandler.php # Global error/exception handler
|
||||
│ ├── NotificationHelper.php # Matrix hookshot webhook events
|
||||
│ ├── OutputHelper.php # Safe HTML output helpers
|
||||
│ ├── ResponseHelper.php # JSON API response helpers
|
||||
│ ├── SynapseHelper.php # Resolves usernames → Matrix IDs via Synapse admin API
|
||||
│ └── UrlHelper.php # Canonical ticket URLs using APP_DOMAIN
|
||||
@@ -542,7 +555,7 @@ Key conventions and gotchas for working with this codebase:
|
||||
21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`.
|
||||
22. **`utils.js` on all pages**: `utils.js` is loaded by all views (including admin). It provides `escapeHtml()`, `getTicketIdFromUrl()`, and `showConfirmModal()`.
|
||||
23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`.
|
||||
24. **Stats cache**: `StatsModel` caches stats for 60 s. Any path that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after the change. Callers: `TicketController::create` (manual create), `create_ticket_api.php` (external API create/escalate/reopen), `cron/create_recurring_tickets.php`, `bulk_operation`, `assign_ticket`, `update_ticket`, and `clone_ticket`.
|
||||
24. **Stats cache**: `StatsModel` caches stats for 60 s. Any path that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after the change. Callers: `TicketController::create` (manual create), `create_ticket_api.php` (external API create/escalate/reopen), `cron/create_recurring_tickets.php`, `bulk_operation`, `assign_ticket`, `update_ticket`, `clone_ticket`, and `ticket_status_api.php` (Bearer API status-change endpoint).
|
||||
25. **External API (`create_ticket_api.php`)**: Uses `ApiKeyAuth` (Bearer token), not session auth. Served directly by the web server from the document root — not through the index.php router. Includes deduplication logic (SHA-256 hash, no time window) that updates/escalates an existing open duplicate or reopens a closed one rather than creating a new ticket.
|
||||
|
||||
## File Reference
|
||||
|
||||
@@ -99,6 +99,11 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Persist the trimmed text (not the raw client value) — matches update_comment.php
|
||||
// and keeps stored comment_text free of leading whitespace that could shift a
|
||||
// markdown-enabled comment's first line out of column 0 on reload.
|
||||
$data['comment_text'] = $commentTextRaw;
|
||||
|
||||
// Never trust a client-supplied display name — always attribute the comment to
|
||||
// the authenticated session user.
|
||||
$data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
|
||||
|
||||
@@ -107,10 +107,17 @@ $result = $bulkOpsModel->processBulkOperation($operationId);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$conn->close();
|
||||
echo json_encode([
|
||||
$response = [
|
||||
'success' => false,
|
||||
'error' => $result['error']
|
||||
]);
|
||||
];
|
||||
// Let the client know it should collect a comment and retry, rather than
|
||||
// showing the failure as a dead end.
|
||||
if (!empty($result['requires_comment'])) {
|
||||
$response['requires_comment'] = true;
|
||||
http_response_code(400);
|
||||
}
|
||||
echo json_encode($response);
|
||||
} else {
|
||||
// Invalidate stats cache so dashboard tiles reflect changes immediately
|
||||
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||
|
||||
@@ -59,12 +59,19 @@ try {
|
||||
|
||||
$keyName = trim($input['key_name'] ?? '');
|
||||
$expiresInDays = $input['expires_in_days'] ?? null;
|
||||
$scope = $input['scope'] ?? 'read_write';
|
||||
|
||||
if (empty($keyName)) {
|
||||
http_response_code(400);
|
||||
throw new Exception("Key name is required");
|
||||
}
|
||||
|
||||
// Validate scope — only the two known values are allowed
|
||||
if (!in_array($scope, ['read', 'read_write'], true)) {
|
||||
http_response_code(400);
|
||||
throw new Exception("Invalid scope: must be 'read' or 'read_write'");
|
||||
}
|
||||
|
||||
if (strlen($keyName) > 100) {
|
||||
http_response_code(400);
|
||||
throw new Exception("Key name must be 100 characters or less");
|
||||
@@ -86,7 +93,7 @@ try {
|
||||
|
||||
// Generate API key
|
||||
$apiKeyModel = new ApiKeyModel($conn);
|
||||
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays);
|
||||
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays, $scope);
|
||||
|
||||
if (!$result['success']) {
|
||||
throw new Exception($result['error'] ?? "Failed to generate API key");
|
||||
@@ -99,7 +106,7 @@ try {
|
||||
'create',
|
||||
'api_key',
|
||||
$result['key_id'],
|
||||
['key_name' => $keyName, 'expires_in_days' => $expiresInDays]
|
||||
['key_name' => $keyName, 'expires_in_days' => $expiresInDays, 'scope' => $scope]
|
||||
);
|
||||
|
||||
// Clear output buffer
|
||||
@@ -112,6 +119,7 @@ try {
|
||||
'api_key' => $result['api_key'],
|
||||
'key_prefix' => $result['key_prefix'],
|
||||
'key_id' => $result['key_id'],
|
||||
'scope' => $result['scope'],
|
||||
'expires_at' => $result['expires_at']
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
|
||||
+4
-2
@@ -8,8 +8,10 @@
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
try {
|
||||
// Get all users for mentions/assignment
|
||||
$result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username");
|
||||
// Get all users for mentions/assignment. Capped as defense-in-depth against
|
||||
// a single call scraping an unbounded user list — every caller only needs
|
||||
// this for typeahead/dropdown filtering, never a literal full roster.
|
||||
$result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username LIMIT 500");
|
||||
|
||||
if (!$result) {
|
||||
throw new Exception("Failed to query users");
|
||||
|
||||
@@ -129,6 +129,39 @@ if (version_compare(PHP_VERSION, $requirements['min_php_version'], '>=')) {
|
||||
$healthy = false;
|
||||
}
|
||||
|
||||
// Check 7: memory_limit / max_execution_time sanity (warnings, not fatal — a
|
||||
// low default doesn't fail requests until something large actually runs, so
|
||||
// surface it here rather than waiting for a mysterious failure under load).
|
||||
$memLimitIni = ini_get('memory_limit');
|
||||
$memLimitUnit = strtolower(substr(trim($memLimitIni), -1));
|
||||
$memLimitBytes = $memLimitIni === '-1'
|
||||
? -1
|
||||
: (int)$memLimitIni * match ($memLimitUnit) {
|
||||
'g' => 1024 * 1024 * 1024,
|
||||
'm' => 1024 * 1024,
|
||||
'k' => 1024,
|
||||
default => 1,
|
||||
};
|
||||
$minMemBytes = $requirements['min_memory_limit_mb'] * 1024 * 1024;
|
||||
if ($memLimitBytes === -1 || $memLimitBytes >= $minMemBytes) {
|
||||
$checks['memory_limit'] = ['status' => 'ok', 'message' => $memLimitIni];
|
||||
} else {
|
||||
$checks['memory_limit'] = [
|
||||
'status' => 'warning',
|
||||
'message' => sprintf('%s is below the recommended minimum %dM', $memLimitIni, $requirements['min_memory_limit_mb'])
|
||||
];
|
||||
}
|
||||
|
||||
$maxExecTime = (int)ini_get('max_execution_time');
|
||||
if ($maxExecTime === 0 || $maxExecTime >= $requirements['min_max_execution_time']) {
|
||||
$checks['max_execution_time'] = ['status' => 'ok', 'message' => (string)$maxExecTime];
|
||||
} else {
|
||||
$checks['max_execution_time'] = [
|
||||
'status' => 'warning',
|
||||
'message' => sprintf('%ds is below the recommended minimum %ds', $maxExecTime, $requirements['min_max_execution_time'])
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate response time
|
||||
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
|
||||
|
||||
|
||||
+15
-4
@@ -225,10 +225,21 @@ foreach ($all as $row) {
|
||||
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
|
||||
'mention' => "{$row['actor_name']} mentioned you on ticket #{$ticketId}",
|
||||
'update' => (function () use ($row, $details, $ticketId) {
|
||||
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}}
|
||||
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
|
||||
$to = $details['status']['to'] ?? ($details['new_value'] ?? '?');
|
||||
return "{$row['actor_name']} changed status on #{$ticketId}: {$from} → {$to}";
|
||||
// Visibility changes log a flat {field, from, to} shape (api/update_ticket.php).
|
||||
if (isset($details['field'], $details['from'], $details['to'])) {
|
||||
return "{$row['actor_name']} changed {$details['field']} on #{$ticketId}: {$details['from']} → {$details['to']}";
|
||||
}
|
||||
|
||||
// Single/bulk field updates log a per-field delta, e.g.
|
||||
// {"status": {"from": "Open", "to": "In Progress"}}. Only one field
|
||||
// changed at a time is reported, in priority order below.
|
||||
foreach (['status', 'priority', 'title', 'category', 'type', 'description'] as $field) {
|
||||
if (isset($details[$field]['from'], $details[$field]['to'])) {
|
||||
return "{$row['actor_name']} changed {$field} on #{$ticketId}: {$details[$field]['from']} → {$details[$field]['to']}";
|
||||
}
|
||||
}
|
||||
|
||||
return "{$row['actor_name']} updated ticket #{$ticketId}";
|
||||
})(),
|
||||
default => "{$row['actor_name']} updated ticket #{$ticketId}",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* ticket_comment_api.php — Bearer-key endpoint to post a comment on a ticket.
|
||||
*
|
||||
* POST only. Requires 'read_write' scope.
|
||||
*
|
||||
* Identity = PER-KEY LABEL: the comment author (ticket_comments.user_name) is the
|
||||
* API key's key_name and the linked user_id is the key's created_by.
|
||||
*
|
||||
* Body (JSON): {
|
||||
* "ticket_id": "NNN" (required),
|
||||
* "comment_text": "..." (required, non-empty),
|
||||
* "markdown_enabled": bool (optional)
|
||||
* }
|
||||
* Response: {success:true, comment_id:...}
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/middleware/ApiKeyAuth.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
try {
|
||||
$conn = Database::getConnection();
|
||||
} catch (Throwable $e) {
|
||||
error_log('ticket_comment_api: DB connection failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$apiKeyAuth = new ApiKeyAuth($conn);
|
||||
|
||||
try {
|
||||
$apiKeyAuth->authenticate();
|
||||
} catch (Exception $e) {
|
||||
// ApiKeyAuth already sent the 401 response.
|
||||
exit;
|
||||
}
|
||||
|
||||
// Posting a comment is a write — reject 'read' keys with 403 before any mutation.
|
||||
$apiKeyAuth->requireScope('read_write');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed. Use POST.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$context = $apiKeyAuth->getKeyContext();
|
||||
$keyName = $context['key_name'] ?? 'API';
|
||||
$createdBy = ($context['created_by'] ?? null) !== null ? (int)$context['created_by'] : null;
|
||||
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$data = json_decode($rawInput, true);
|
||||
if (!is_array($data)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
|
||||
if ($ticketId === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'ticket_id is required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$commentText = isset($data['comment_text']) ? trim((string)$data['comment_text']) : '';
|
||||
if ($commentText === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'comment_text is required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$markdownEnabled = !empty($data['markdown_enabled']);
|
||||
|
||||
// Validate the ticket exists.
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$ticket = $ticketModel->getTicketById($ticketId);
|
||||
if (!$ticket) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Post the comment under the key's label / owner.
|
||||
$commentModel = new CommentModel($conn);
|
||||
$result = $commentModel->addComment($ticketId, [
|
||||
'user_name' => $keyName,
|
||||
'comment_text' => $commentText,
|
||||
'markdown_enabled' => $markdownEnabled,
|
||||
], $createdBy);
|
||||
|
||||
if (empty($result['success'])) {
|
||||
error_log('ticket_comment_api: addComment failed for ticket ' . $ticketId
|
||||
. ': ' . ($result['error'] ?? 'unknown'));
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to add comment']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$commentId = $result['comment_id'] ?? null;
|
||||
|
||||
// Audit trail (action 'comment' / entity 'comment' are both whitelisted).
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$auditLog->log($createdBy, 'comment', 'comment', (string)$commentId, [
|
||||
'ticket_id' => $ticketId,
|
||||
'key_name' => $keyName,
|
||||
'via_api' => true,
|
||||
]);
|
||||
|
||||
echo json_encode(['success' => true, 'comment_id' => $commentId]);
|
||||
exit;
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* ticket_status_api.php — Bearer-key endpoint to change a ticket's status.
|
||||
*
|
||||
* POST only. Requires 'read_write' scope.
|
||||
*
|
||||
* Body (JSON): {
|
||||
* "ticket_id": "NNN" (required),
|
||||
* "status": "..." (required target status),
|
||||
* "comment": "..." (optional; REQUIRED when the transition
|
||||
* requires_comment),
|
||||
* "markdown_enabled": bool (optional, applies to the comment)
|
||||
* }
|
||||
* Response: {success:true, ticket_id, status}
|
||||
*
|
||||
* Mirrors api/update_ticket.php: workflow validation, requires_comment
|
||||
* enforcement, updateTicket (updated_by/updated_at + closed_at handling), Matrix
|
||||
* status-change notification, and StatsModel cache invalidation. When a comment
|
||||
* is supplied it is posted first (per-key label) so "close with reason" is one call.
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/middleware/ApiKeyAuth.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||
|
||||
try {
|
||||
$conn = Database::getConnection();
|
||||
} catch (Throwable $e) {
|
||||
error_log('ticket_status_api: DB connection failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$apiKeyAuth = new ApiKeyAuth($conn);
|
||||
|
||||
try {
|
||||
$apiKeyAuth->authenticate();
|
||||
} catch (Exception $e) {
|
||||
// ApiKeyAuth already sent the 401 response.
|
||||
exit;
|
||||
}
|
||||
|
||||
// Changing status is a write — reject 'read' keys with 403 before any mutation.
|
||||
$apiKeyAuth->requireScope('read_write');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed. Use POST.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$context = $apiKeyAuth->getKeyContext();
|
||||
$keyName = $context['key_name'] ?? 'API';
|
||||
$createdBy = ($context['created_by'] ?? null) !== null ? (int)$context['created_by'] : null;
|
||||
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$data = json_decode($rawInput, true);
|
||||
if (!is_array($data)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
|
||||
if ($ticketId === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'ticket_id is required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$newStatus = isset($data['status']) ? trim((string)$data['status']) : '';
|
||||
if ($newStatus === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'status is required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$comment = isset($data['comment']) ? trim((string)$data['comment']) : '';
|
||||
|
||||
// Validate the ticket exists.
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$ticket = $ticketModel->getTicketById($ticketId);
|
||||
if (!$ticket) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$currentStatus = (string)$ticket['status'];
|
||||
|
||||
// Validate the transition (API key is never admin).
|
||||
$workflowModel = new WorkflowModel($conn);
|
||||
if (!$workflowModel->isTransitionAllowed($currentStatus, $newStatus, false)) {
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Status transition not allowed: ' . $currentStatus . ' -> ' . $newStatus,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Enforce requires_comment transitions server-side.
|
||||
if ($workflowModel->transitionRequiresComment($currentStatus, $newStatus) && $comment === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'A comment is required for this status change',
|
||||
'requires_comment' => true,
|
||||
]);
|
||||
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
|
||||
// closed_at (set on close, cleared on reopen) via its own SQL.
|
||||
$updateData = [
|
||||
'ticket_id' => $ticketId,
|
||||
'title' => $ticket['title'],
|
||||
'description' => $ticket['description'],
|
||||
'category' => $ticket['category'],
|
||||
'type' => $ticket['type'],
|
||||
'status' => $newStatus,
|
||||
'priority' => (int)$ticket['priority'],
|
||||
];
|
||||
|
||||
$updateResult = $ticketModel->updateTicket($updateData, $createdBy);
|
||||
if (empty($updateResult['success'])) {
|
||||
error_log('ticket_status_api: updateTicket failed for ticket ' . $ticketId
|
||||
. ': ' . ($updateResult['error'] ?? 'unknown'));
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to update ticket status']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Notify, audit, and refresh stats only when the status actually changed.
|
||||
if ($currentStatus !== $newStatus) {
|
||||
NotificationHelper::sendStatusChangeNotification(
|
||||
$ticketId,
|
||||
$currentStatus,
|
||||
$newStatus,
|
||||
(string)$ticket['title'],
|
||||
$keyName
|
||||
);
|
||||
NotificationHelper::notifyWatchers(
|
||||
$conn,
|
||||
$ticketId,
|
||||
(string)$ticket['title'],
|
||||
'status_changed',
|
||||
['old_status' => $currentStatus, 'new_status' => $newStatus, 'changed_by' => $keyName],
|
||||
$createdBy,
|
||||
$ticket['visibility'] ?? 'public'
|
||||
);
|
||||
|
||||
// Audit trail (action 'update' / entity 'ticket' are both whitelisted).
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$auditLog->log($createdBy, 'update', 'ticket', $ticketId, [
|
||||
'status' => ['from' => $currentStatus, 'to' => $newStatus],
|
||||
'key_name' => $keyName,
|
||||
'via_api' => true,
|
||||
]);
|
||||
|
||||
// Status change is a ticket-state change — refresh dashboard stats.
|
||||
(new StatsModel($conn))->invalidateCache();
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'ticket_id' => $ticketId,
|
||||
'status' => $newStatus,
|
||||
]);
|
||||
exit;
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* tickets_api.php — Bearer-key read endpoint (list/triage + read-one).
|
||||
*
|
||||
* GET only. Requires 'read' scope (a 'read_write' key also satisfies it).
|
||||
* Acts as a trusted automation/server credential: reads return the full queue
|
||||
* (no per-user visibility filtering).
|
||||
*
|
||||
* GET ?ticket_id=NNN -> {success, ticket, comments}
|
||||
* GET ?status=&priority=&host= -> {success, tickets, page, total, pages}
|
||||
* &page=&limit=
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
|
||||
// Rate limiting (same pattern as the other Bearer API endpoints)
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/middleware/ApiKeyAuth.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||
|
||||
try {
|
||||
$conn = Database::getConnection();
|
||||
} catch (Throwable $e) {
|
||||
error_log('tickets_api: DB connection failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$apiKeyAuth = new ApiKeyAuth($conn);
|
||||
|
||||
try {
|
||||
$apiKeyAuth->authenticate();
|
||||
} catch (Exception $e) {
|
||||
// ApiKeyAuth already sent the 401 response.
|
||||
exit;
|
||||
}
|
||||
|
||||
// Reads only need the 'read' scope.
|
||||
$apiKeyAuth->requireScope('read');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed. Use GET.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ticketModel = new TicketModel($conn);
|
||||
|
||||
// ── READ ONE ──────────────────────────────────────────────────────────────
|
||||
if (isset($_GET['ticket_id']) && trim((string)$_GET['ticket_id']) !== '') {
|
||||
$ticketId = trim((string)$_GET['ticket_id']);
|
||||
|
||||
$ticket = $ticketModel->getTicketById($ticketId);
|
||||
if (!$ticket) {
|
||||
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.
|
||||
$commentModel = new CommentModel($conn);
|
||||
$comments = $commentModel->getCommentsByTicketId($ticketId, false);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'ticket' => $ticket,
|
||||
'comments' => $comments,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── LIST / TRIAGE ───────────────────────────────────────────────────────────
|
||||
$status = (isset($_GET['status']) && trim((string)$_GET['status']) !== '')
|
||||
? trim((string)$_GET['status'])
|
||||
: 'Open';
|
||||
|
||||
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
|
||||
if ($page < 1) {
|
||||
$page = 1;
|
||||
}
|
||||
|
||||
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 25;
|
||||
if ($limit < 1) {
|
||||
$limit = 25;
|
||||
}
|
||||
if ($limit > 100) {
|
||||
$limit = 100; // cap
|
||||
}
|
||||
|
||||
$filters = [];
|
||||
if (isset($_GET['priority']) && trim((string)$_GET['priority']) !== '') {
|
||||
$priority = (int)$_GET['priority'];
|
||||
if ($priority >= 1 && $priority <= 5) {
|
||||
// Exact-priority match via the min/max range filter.
|
||||
$filters['priority_min'] = $priority;
|
||||
$filters['priority_max'] = $priority;
|
||||
}
|
||||
}
|
||||
|
||||
// hwmon puts the host in the title (e.g. "[hostname] ..."), so a host filter is a
|
||||
// title substring match — served by getAllTickets's `search` param (title search).
|
||||
$search = null;
|
||||
if (isset($_GET['host']) && trim((string)$_GET['host']) !== '') {
|
||||
$search = trim((string)$_GET['host']);
|
||||
}
|
||||
|
||||
// user = null => getAllTickets skips visibility filtering and returns the full
|
||||
// queue (this is a trusted server credential, not an end user).
|
||||
$result = $ticketModel->getAllTickets(
|
||||
$page,
|
||||
$limit,
|
||||
$status,
|
||||
'ticket_id',
|
||||
'desc',
|
||||
null,
|
||||
null,
|
||||
$search,
|
||||
$filters,
|
||||
null
|
||||
);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'tickets' => $result['tickets'],
|
||||
'page' => $result['current_page'],
|
||||
'total' => $result['total'],
|
||||
'pages' => $result['pages'],
|
||||
]);
|
||||
exit;
|
||||
@@ -29,6 +29,73 @@ require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
/**
|
||||
* Strip EXIF/metadata (including GPS) from an image file in place by
|
||||
* decoding and re-encoding it via GD, which drops metadata chunks that
|
||||
* aren't part of the pixel data. Best-effort: leaves the file untouched on
|
||||
* any failure (corrupt image, unsupported format, GD unavailable) rather
|
||||
* than blocking the upload — original bytes are what would have been stored
|
||||
* anyway before this existed.
|
||||
*
|
||||
* download_attachment.php streams attachments back byte-for-byte to any user
|
||||
* with ticket visibility, so an unstripped phone photo's embedded GPS data
|
||||
* would otherwise leak a data center/office's physical location even on a
|
||||
* Confidential-visibility ticket.
|
||||
*/
|
||||
function stripImageMetadata(string $path, string $mimeType): void
|
||||
{
|
||||
if (!extension_loaded('gd')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard against a decompression-bomb-style crafted image (small file,
|
||||
// huge decoded pixel buffer) exhausting memory during decode.
|
||||
$dims = @getimagesize($path);
|
||||
if ($dims === false) {
|
||||
return;
|
||||
}
|
||||
[$width, $height] = $dims;
|
||||
if ($width * $height > 40_000_000) { // ~40 MP cap
|
||||
return;
|
||||
}
|
||||
|
||||
$loaders = [
|
||||
'image/jpeg' => 'imagecreatefromjpeg',
|
||||
'image/png' => 'imagecreatefrompng',
|
||||
'image/gif' => 'imagecreatefromgif',
|
||||
'image/webp' => 'imagecreatefromwebp',
|
||||
];
|
||||
$loader = $loaders[$mimeType] ?? null;
|
||||
if ($loader === null || !function_exists($loader)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$image = @$loader($path);
|
||||
if ($image === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Preserve transparency for formats that support it.
|
||||
imagesavealpha($image, true);
|
||||
imagealphablending($image, false);
|
||||
|
||||
$tmpPath = $path . '.tmp';
|
||||
$saved = match ($mimeType) {
|
||||
'image/jpeg' => imagejpeg($image, $tmpPath, 90),
|
||||
'image/png' => imagepng($image, $tmpPath, 6),
|
||||
'image/gif' => imagegif($image, $tmpPath),
|
||||
'image/webp' => imagewebp($image, $tmpPath, 90),
|
||||
default => false,
|
||||
};
|
||||
imagedestroy($image);
|
||||
|
||||
if ($saved && file_exists($tmpPath)) {
|
||||
rename($tmpPath, $path);
|
||||
} elseif (file_exists($tmpPath)) {
|
||||
unlink($tmpPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
ResponseHelper::unauthorized();
|
||||
@@ -127,6 +194,23 @@ if ($file['size'] > $maxSize) {
|
||||
ResponseHelper::error('File size exceeds maximum allowed (' . AttachmentModel::formatFileSize($maxSize) . ')');
|
||||
}
|
||||
|
||||
// Check per-ticket attachment count/storage quota — bounds an authenticated
|
||||
// low-privilege user slowly filling the uploads/ disk across many tickets,
|
||||
// which was previously bounded only by the request-rate limiter, not volume.
|
||||
$attachmentModel = new AttachmentModel($conn);
|
||||
$maxAttachments = $GLOBALS['config']['MAX_ATTACHMENTS_PER_TICKET'] ?? 50;
|
||||
if ($attachmentModel->getAttachmentCount($ticketId) >= $maxAttachments) {
|
||||
ResponseHelper::error("This ticket already has the maximum of {$maxAttachments} attachments");
|
||||
}
|
||||
|
||||
$maxTotalSize = $GLOBALS['config']['MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET'] ?? 104857600;
|
||||
if ($attachmentModel->getTotalSizeForTicket($ticketId) + $file['size'] > $maxTotalSize) {
|
||||
ResponseHelper::error(
|
||||
'This upload would exceed the ticket\'s total attachment size limit of '
|
||||
. AttachmentModel::formatFileSize($maxTotalSize)
|
||||
);
|
||||
}
|
||||
|
||||
// Get MIME type
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->file($file['tmp_name']);
|
||||
@@ -184,6 +268,11 @@ if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
|
||||
ResponseHelper::serverError('Failed to move uploaded file');
|
||||
}
|
||||
|
||||
// Strip EXIF/GPS metadata from image uploads before it's ever served back
|
||||
if (str_starts_with($mimeType, 'image/')) {
|
||||
stripImageMetadata($targetPath, $mimeType);
|
||||
}
|
||||
|
||||
// Sanitize original filename
|
||||
$originalFilename = basename($file['name']);
|
||||
$originalFilename = preg_replace('/[^\w\s\-\.]/', '', $originalFilename);
|
||||
@@ -193,7 +282,6 @@ if (empty($originalFilename)) {
|
||||
|
||||
// Save to database
|
||||
try {
|
||||
$attachmentModel = new AttachmentModel($conn);
|
||||
$attachmentId = $attachmentModel->addAttachment(
|
||||
$ticketId,
|
||||
$uniqueFilename,
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
--accent-green-bright: #33FFAA;
|
||||
--accent-green-dim: rgba(0,255,136,0.10);
|
||||
--accent-green-border: rgba(0,255,136,0.22);
|
||||
--shadow-color: rgba(0,0,0,0.5);
|
||||
|
||||
/* --- Error / Critical --- */
|
||||
--accent-red: #FF2D55;
|
||||
@@ -3640,6 +3641,8 @@ html[data-theme="light"] {
|
||||
--accent-red-dim: rgba(181,0,31,0.10);
|
||||
--accent-amber-dim: rgba(138,90,0,0.10);
|
||||
--accent-cyan-border: rgba(0,98,184,0.28);
|
||||
--accent-green-border: rgba(0,109,53,0.28);
|
||||
--shadow-color: rgba(50,80,130,0.18);
|
||||
|
||||
/* — Glows become subtle drop shadows in light mode — */
|
||||
--glow-orange: 0 0 0 1px rgba(196,78,0,0.25), 0 1px 6px rgba(196,78,0,0.18);
|
||||
@@ -3737,6 +3740,25 @@ html[data-theme="light"] .lt-textarea:focus-visible {
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: var(--box-glow-cyan);
|
||||
}
|
||||
|
||||
/* Native <select> popup in light mode.
|
||||
`.lt-select` sets `color-scheme: dark` on the element itself, which beats the
|
||||
`color-scheme: light` declared on <html>, so the browser drew the dropdown with
|
||||
dark chrome even in light mode. Reset it per element, and re-tint the option
|
||||
list, which is otherwise hardcoded to #0d1117 for the dark theme. */
|
||||
html[data-theme="light"] .lt-select { color-scheme: light; }
|
||||
html[data-theme="light"] .lt-select option,
|
||||
html[data-theme="light"] select option {
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
html[data-theme="light"] .lt-select option:hover,
|
||||
html[data-theme="light"] .lt-select option:focus,
|
||||
html[data-theme="light"] .lt-select option:checked,
|
||||
html[data-theme="light"] select option:checked {
|
||||
background: var(--accent-orange-dim);
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
html[data-theme="light"] .lt-label { color: var(--text-muted); }
|
||||
|
||||
/* — Buttons — */
|
||||
|
||||
@@ -338,17 +338,22 @@ kbd {
|
||||
}
|
||||
|
||||
/* ── Ticket preview popup ────────────────────────────────────── */
|
||||
/* --lt-surface is not defined anywhere, so the background always fell through to
|
||||
the hardcoded #0a0e14 — a near-black panel in light mode, with no colour set at
|
||||
all, so the inherited near-black body text was invisible on it. These tokens
|
||||
are redefined for light mode in base.css. */
|
||||
.ticket-preview-popup {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
background: var(--lt-surface, #0a0e14);
|
||||
border: 1px solid rgba(0, 255, 65, 0.4);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--accent-green-border);
|
||||
padding: 0.75rem;
|
||||
min-width: 280px;
|
||||
max-width: 360px;
|
||||
font-size: 0.75rem;
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||
box-shadow: 0 4px 20px var(--shadow-color);
|
||||
}
|
||||
.ticket-preview-popup .preview-header {
|
||||
display: flex;
|
||||
@@ -356,7 +361,7 @@ kbd {
|
||||
align-items: center;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.ticket-preview-popup .preview-id { color: var(--lt-cyan, #00ffff); font-weight: 700; }
|
||||
.ticket-preview-popup .preview-id { color: var(--accent-cyan); font-weight: 700; }
|
||||
.ticket-preview-popup .preview-title { font-weight: 600; margin-bottom: 0.4rem; }
|
||||
.ticket-preview-popup .preview-meta { opacity: 0.7; display: flex; flex-direction: column; gap: 0.1rem; }
|
||||
.ticket-preview-popup .preview-footer { margin-top: 0.4rem; opacity: 0.5; font-size: 0.65rem; }
|
||||
|
||||
+12
-4
@@ -54,10 +54,18 @@ body.edit-mode .editable-metadata {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.lt-status-select.lt-status-open { color: var(--lt-success, #00ff41); border-color: var(--lt-success, #00ff41); }
|
||||
.lt-status-select.lt-status-pending { color: var(--lt-amber, #ffb000); border-color: var(--lt-amber, #ffb000); }
|
||||
.lt-status-select.lt-status-in-progress { color: var(--lt-cyan, #00ffff); border-color: var(--lt-cyan, #00ffff); }
|
||||
.lt-status-select.lt-status-closed { color: var(--lt-danger, #ff4d4d); border-color: var(--lt-danger, #ff4d4d); }
|
||||
/* --lt-success / --lt-amber / --lt-cyan / --lt-danger are not defined anywhere,
|
||||
so these always fell through to the hardcoded neon fallbacks — unreadable on
|
||||
the light theme's white input background. The --accent-* tokens carry the same
|
||||
hues and are redefined for light mode in base.css.
|
||||
|
||||
The leading .lt-select is needed for specificity: base.css's
|
||||
`html[data-theme="light"] .lt-select` (0,2,1) would otherwise outrank a plain
|
||||
two-class selector and repaint every status the same near-black. */
|
||||
.lt-select.lt-status-select.lt-status-open { color: var(--accent-green); border-color: var(--accent-green); }
|
||||
.lt-select.lt-status-select.lt-status-pending { color: var(--accent-amber); border-color: var(--accent-amber); }
|
||||
.lt-select.lt-status-select.lt-status-in-progress { color: var(--accent-cyan); border-color: var(--accent-cyan); }
|
||||
.lt-select.lt-status-select.lt-status-closed { color: var(--accent-red); border-color: var(--accent-red); }
|
||||
|
||||
/* ── Ticket meta KV grid ─────────────────────────────────────── */
|
||||
.ticket-meta-grid {
|
||||
|
||||
@@ -87,11 +87,17 @@ function performAdvancedSearch(event) {
|
||||
params.set('search', searchText);
|
||||
}
|
||||
|
||||
// Date ranges
|
||||
const createdFrom = document.getElementById('adv-created-from').value;
|
||||
const createdTo = document.getElementById('adv-created-to').value;
|
||||
const updatedFrom = document.getElementById('adv-updated-from').value;
|
||||
const updatedTo = document.getElementById('adv-updated-to').value;
|
||||
// Date ranges — swap if the user entered an end date before the start date
|
||||
let createdFrom = document.getElementById('adv-created-from').value;
|
||||
let createdTo = document.getElementById('adv-created-to').value;
|
||||
if (createdFrom && createdTo && createdFrom > createdTo) {
|
||||
[createdFrom, createdTo] = [createdTo, createdFrom];
|
||||
}
|
||||
let updatedFrom = document.getElementById('adv-updated-from').value;
|
||||
let updatedTo = document.getElementById('adv-updated-to').value;
|
||||
if (updatedFrom && updatedTo && updatedFrom > updatedTo) {
|
||||
[updatedFrom, updatedTo] = [updatedTo, updatedFrom];
|
||||
}
|
||||
|
||||
if (createdFrom) params.set('created_from', createdFrom);
|
||||
if (createdTo) params.set('created_to', createdTo);
|
||||
@@ -105,9 +111,12 @@ function performAdvancedSearch(event) {
|
||||
params.set('status', selectedStatuses.join(','));
|
||||
}
|
||||
|
||||
// Priority range
|
||||
const priorityMin = document.getElementById('adv-priority-min').value;
|
||||
const priorityMax = document.getElementById('adv-priority-max').value;
|
||||
// Priority range — swap if min > max so the range is always satisfiable
|
||||
let priorityMin = document.getElementById('adv-priority-min').value;
|
||||
let priorityMax = document.getElementById('adv-priority-max').value;
|
||||
if (priorityMin && priorityMax && Number(priorityMin) > Number(priorityMax)) {
|
||||
[priorityMin, priorityMax] = [priorityMax, priorityMin];
|
||||
}
|
||||
if (priorityMin) params.set('priority_min', priorityMin);
|
||||
if (priorityMax) params.set('priority_max', priorityMax);
|
||||
|
||||
|
||||
@@ -241,6 +241,11 @@
|
||||
trigger.focus();
|
||||
}
|
||||
}
|
||||
// Announce the close so whoever opened the modal can undo optimistic UI or
|
||||
// clean up a dynamically-inserted overlay. A modal can be dismissed four
|
||||
// ways — the ✕ button, a Cancel button, a backdrop click, and Escape — and
|
||||
// the last two are handled globally here, so button-only listeners miss them.
|
||||
el.dispatchEvent(new CustomEvent('lt:modalclose', { bubbles: true }));
|
||||
}
|
||||
|
||||
function closeAllModals() {
|
||||
@@ -2470,6 +2475,101 @@
|
||||
|
||||
list.addEventListener('drop', e => { e.preventDefault(); });
|
||||
|
||||
// Touch fallback — iOS Safari doesn't implement HTML5 drag-and-drop on
|
||||
// arbitrary elements at all, and mobile Chrome's support is poor, so
|
||||
// kanban drag was effectively unusable via touch without this. Touch
|
||||
// events for a given touch point are always dispatched to the element
|
||||
// touchstart fired on (per spec), so per-list local state here is safe;
|
||||
// cross-list moves are resolved via elementFromPoint against the live
|
||||
// finger position, same as dragover does via e.target above.
|
||||
const DRAG_THRESHOLD = 8; // px of movement before a touch starts a drag
|
||||
let _touchItem = null, _touchDragging = false;
|
||||
let _touchStartX = 0, _touchStartY = 0, _touchOffsetX = 0, _touchOffsetY = 0;
|
||||
|
||||
function _touchTargetList(x, y) {
|
||||
const el = document.elementFromPoint(x, y);
|
||||
const found = el ? el.closest('[data-sortable-group]') : null;
|
||||
return found && (found === list || _sameGroup(found)) ? found : null;
|
||||
}
|
||||
|
||||
list.addEventListener('touchstart', e => {
|
||||
const item = e.target.closest('[data-sortable-item]');
|
||||
if (!item || !list.contains(item)) return;
|
||||
if (handle && !e.target.closest(handle)) return;
|
||||
const t = e.touches[0];
|
||||
_touchItem = item;
|
||||
_touchDragging = false;
|
||||
_touchStartX = t.clientX;
|
||||
_touchStartY = t.clientY;
|
||||
}, { passive: true });
|
||||
|
||||
// touchmove/touchend/touchcancel are registered on document, not list:
|
||||
// once the dragged item is reparented to document.body below, it's no
|
||||
// longer a descendant of list, so events targeting it (touch events
|
||||
// keep targeting their touchstart element for the whole gesture) would
|
||||
// stop bubbling to a listener on list.
|
||||
document.addEventListener('touchmove', e => {
|
||||
if (!_touchItem) return;
|
||||
const t = e.touches[0];
|
||||
|
||||
if (!_touchDragging) {
|
||||
if (Math.abs(t.clientX - _touchStartX) < DRAG_THRESHOLD && Math.abs(t.clientY - _touchStartY) < DRAG_THRESHOLD) return;
|
||||
// Drag intent confirmed — take over from here, blocking page scroll.
|
||||
_touchDragging = true;
|
||||
_srtDragging = _touchItem;
|
||||
_srtSrcList = list;
|
||||
_srtPlaceholder = _makePlaceholder(_touchItem);
|
||||
_touchItem.classList.add('is-dragging');
|
||||
const rect = _touchItem.getBoundingClientRect();
|
||||
_touchOffsetX = _touchStartX - rect.left;
|
||||
_touchOffsetY = _touchStartY - rect.top;
|
||||
_touchItem.parentNode.insertBefore(_srtPlaceholder, _touchItem);
|
||||
_touchItem.style.position = 'fixed';
|
||||
_touchItem.style.zIndex = '1000';
|
||||
_touchItem.style.width = rect.width + 'px';
|
||||
_touchItem.style.pointerEvents = 'none';
|
||||
document.body.appendChild(_touchItem); // avoid clipping by an overflow:hidden ancestor
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
_touchItem.style.left = (t.clientX - _touchOffsetX) + 'px';
|
||||
_touchItem.style.top = (t.clientY - _touchOffsetY) + 'px';
|
||||
|
||||
const targetList = _touchTargetList(t.clientX, t.clientY);
|
||||
if (!targetList) return;
|
||||
const overEl = document.elementFromPoint(t.clientX, t.clientY);
|
||||
const over = overEl ? overEl.closest('[data-sortable-item]') : null;
|
||||
if (over && over !== _srtDragging && targetList.contains(over)) {
|
||||
const rect = over.getBoundingClientRect();
|
||||
targetList.insertBefore(_srtPlaceholder, t.clientY < rect.top + rect.height / 2 ? over : over.nextSibling);
|
||||
} else if (!targetList.contains(_srtPlaceholder)) {
|
||||
targetList.appendChild(_srtPlaceholder);
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
function _touchEnd() {
|
||||
if (_touchDragging && _srtDragging) {
|
||||
_srtDragging.classList.remove('is-dragging');
|
||||
_srtDragging.style.position = '';
|
||||
_srtDragging.style.zIndex = '';
|
||||
_srtDragging.style.width = '';
|
||||
_srtDragging.style.pointerEvents = '';
|
||||
_srtDragging.style.left = '';
|
||||
_srtDragging.style.top = '';
|
||||
if (_srtPlaceholder && _srtPlaceholder.parentNode) {
|
||||
_srtPlaceholder.parentNode.insertBefore(_srtDragging, _srtPlaceholder);
|
||||
_srtPlaceholder.remove();
|
||||
}
|
||||
if (onSort) onSort(_getItems(), _srtDragging);
|
||||
bus.emit('sortable:change', { list, items: _getItems(), moved: _srtDragging });
|
||||
}
|
||||
_touchItem = null; _touchDragging = false;
|
||||
_srtDragging = null; _srtPlaceholder = null; _srtSrcList = null;
|
||||
}
|
||||
|
||||
document.addEventListener('touchend', _touchEnd);
|
||||
document.addEventListener('touchcancel', _touchEnd);
|
||||
|
||||
return {
|
||||
refresh() { Array.from(list.children).forEach(child => { if (!child.hasAttribute('data-sortable-item')) _mark(child); }); },
|
||||
getOrder: () => _getItems().map(el => el.dataset.id || el.textContent.trim()),
|
||||
@@ -2774,6 +2874,9 @@
|
||||
setTimeout(() => { if (modalEl && modalEl.parentNode) modalEl.remove(); }, 300);
|
||||
resolve(value);
|
||||
};
|
||||
// Any dismissal counts as "no comment given", including a backdrop click or
|
||||
// Escape, which close the overlay through the global handlers above.
|
||||
modalEl.addEventListener('lt:modalclose', () => finish(null));
|
||||
modalEl.querySelector('[data-modal-close]').addEventListener('click', () => finish(null));
|
||||
document.getElementById(modalId + '_cancel').addEventListener('click', () => finish(null));
|
||||
document.getElementById(modalId + '_confirm').addEventListener('click', () => {
|
||||
|
||||
+129
-93
@@ -157,6 +157,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
case 'close-bulk-status-modal':
|
||||
closeBulkStatusModal();
|
||||
break;
|
||||
case 'perform-bulk-close':
|
||||
performBulkCloseAction();
|
||||
break;
|
||||
case 'close-bulk-close-modal':
|
||||
closeBulkCloseModal();
|
||||
break;
|
||||
case 'perform-bulk-delete':
|
||||
performBulkDelete();
|
||||
break;
|
||||
@@ -291,8 +297,12 @@ function clearAllFilters() {
|
||||
params.delete('type');
|
||||
params.delete('assigned_to');
|
||||
params.delete('search');
|
||||
params.delete('date_from');
|
||||
params.delete('date_to');
|
||||
params.delete('created_from');
|
||||
params.delete('created_to');
|
||||
params.delete('updated_from');
|
||||
params.delete('updated_to');
|
||||
params.delete('closed_from');
|
||||
params.delete('closed_to');
|
||||
params.delete('page');
|
||||
|
||||
// Keep sort parameters
|
||||
@@ -375,73 +385,6 @@ function initSettingsModal() {
|
||||
}
|
||||
}
|
||||
|
||||
function sortTable(table, column) {
|
||||
const headers = table.querySelectorAll('th');
|
||||
headers.forEach(header => {
|
||||
header.classList.remove('sort-asc', 'sort-desc');
|
||||
});
|
||||
|
||||
const rows = Array.from(table.querySelectorAll('tbody tr'));
|
||||
const currentDirection = table.dataset.sortColumn == column
|
||||
? (table.dataset.sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
: 'asc';
|
||||
|
||||
table.dataset.sortColumn = column;
|
||||
table.dataset.sortDirection = currentDirection;
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const aValue = a.children[column].textContent.trim();
|
||||
const bValue = b.children[column].textContent.trim();
|
||||
|
||||
// Check if this is a date column — prefer data-ts attribute over text (which may be relative)
|
||||
const headerText = headers[column].textContent.toLowerCase();
|
||||
if (headerText === 'created' || headerText === 'updated') {
|
||||
const cellA = a.children[column];
|
||||
const cellB = b.children[column];
|
||||
const dateA = new Date(cellA.dataset.ts || aValue);
|
||||
const dateB = new Date(cellB.dataset.ts || bValue);
|
||||
return currentDirection === 'asc' ? dateA - dateB : dateB - dateA;
|
||||
}
|
||||
|
||||
// Special handling for "Assigned To" column
|
||||
if (headerText === 'assigned to') {
|
||||
const aUnassigned = aValue === 'Unassigned';
|
||||
const bUnassigned = bValue === 'Unassigned';
|
||||
|
||||
// Both unassigned - equal
|
||||
if (aUnassigned && bUnassigned) return 0;
|
||||
|
||||
// Put unassigned at the end regardless of sort direction
|
||||
if (aUnassigned) return 1;
|
||||
if (bUnassigned) return -1;
|
||||
|
||||
// Otherwise sort names normally
|
||||
return currentDirection === 'asc'
|
||||
? aValue.localeCompare(bValue)
|
||||
: bValue.localeCompare(aValue);
|
||||
}
|
||||
|
||||
// Numeric comparison
|
||||
const numA = parseFloat(aValue);
|
||||
const numB = parseFloat(bValue);
|
||||
|
||||
if (!isNaN(numA) && !isNaN(numB)) {
|
||||
return currentDirection === 'asc' ? numA - numB : numB - numA;
|
||||
}
|
||||
|
||||
// String comparison
|
||||
return currentDirection === 'asc'
|
||||
? aValue.localeCompare(bValue)
|
||||
: bValue.localeCompare(aValue);
|
||||
});
|
||||
|
||||
const currentHeader = headers[column];
|
||||
currentHeader.classList.add(currentDirection === 'asc' ? 'sort-asc' : 'sort-desc');
|
||||
|
||||
const tbody = table.querySelector('tbody');
|
||||
rows.forEach(row => tbody.appendChild(row));
|
||||
}
|
||||
|
||||
// Old settings modal functions removed - now using settings.js with new settings modal
|
||||
|
||||
|
||||
@@ -515,24 +458,59 @@ function bulkClose() {
|
||||
return;
|
||||
}
|
||||
|
||||
showConfirmModal(
|
||||
`Close ${ticketIds.length} Ticket(s)?`,
|
||||
'Are you sure you want to close these tickets?',
|
||||
'warning',
|
||||
() => performBulkCloseAction(ticketIds)
|
||||
);
|
||||
// Closing needs a reason: the default workflow marks every → Closed transition
|
||||
// requires_comment, so collect it here instead of failing server-side.
|
||||
const modalHtml = `
|
||||
<div class="lt-modal-overlay" id="bulkCloseModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkCloseModalTitle">
|
||||
<div class="lt-modal">
|
||||
<div class="lt-modal-header" style="color:var(--terminal-amber)">
|
||||
<span class="lt-modal-title" id="bulkCloseModalTitle">[ ! ] Close ${ticketIds.length} Ticket(s)</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="lt-modal-body">
|
||||
<label for="bulkCloseComment">Close Reason:</label>
|
||||
<textarea id="bulkCloseComment" class="lt-input lt-w-full" rows="3"
|
||||
placeholder="Why are these tickets being closed?…"
|
||||
style="resize:vertical;font-family:inherit;font-size:0.8rem"
|
||||
aria-label="Reason for closing the tickets"></textarea>
|
||||
<p class="lt-text-xs lt-text-muted" style="margin-top:0.35rem">
|
||||
Posted as a comment on every ticket closed. Tickets whose workflow
|
||||
forbids closing from their current status are skipped.
|
||||
</p>
|
||||
</div>
|
||||
<div class="lt-modal-footer">
|
||||
<button data-action="perform-bulk-close" class="lt-btn lt-btn-primary">CLOSE TICKETS</button>
|
||||
<button data-action="close-bulk-close-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
openModalWithDismiss('bulkCloseModal', closeBulkCloseModal);
|
||||
}
|
||||
|
||||
function closeBulkCloseModal() {
|
||||
lt.modal.close('bulkCloseModal');
|
||||
const modal = document.getElementById('bulkCloseModal');
|
||||
if (modal) setTimeout(() => modal.remove(), 300);
|
||||
}
|
||||
|
||||
function performBulkCloseAction(ticketIds) {
|
||||
ticketIds = ticketIds || getSelectedTicketIds();
|
||||
const commentEl = document.getElementById('bulkCloseComment');
|
||||
const comment = commentEl ? commentEl.value.trim() : '';
|
||||
|
||||
lt.api.post('/api/bulk_operation.php', {
|
||||
operation_type: 'bulk_close',
|
||||
ticket_ids: ticketIds
|
||||
ticket_ids: ticketIds,
|
||||
parameters: { comment: comment }
|
||||
})
|
||||
.then(data => {
|
||||
closeBulkCloseModal();
|
||||
if (data.success) {
|
||||
if (data.failed > 0) {
|
||||
lt.toast.warning(`Bulk close: ${data.processed} succeeded, ${data.failed} failed`, 5000);
|
||||
lt.toast.warning(bulkResultMessage('Bulk close', data), 6000);
|
||||
} else {
|
||||
lt.toast.success(`Successfully closed ${data.processed} ticket(s)`, 4000);
|
||||
}
|
||||
@@ -542,6 +520,14 @@ function performBulkCloseAction(ticketIds) {
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
// Missing required comment — keep the modal open so it can be entered.
|
||||
if (error && error.data && error.data.requires_comment) {
|
||||
lt.toast.warning(error.data.error || 'A close reason is required', 6000);
|
||||
const ta = document.getElementById('bulkCloseComment');
|
||||
if (ta) ta.focus();
|
||||
return;
|
||||
}
|
||||
closeBulkCloseModal();
|
||||
lt.toast.error('Bulk close failed: ' + error.message, 5000);
|
||||
});
|
||||
}
|
||||
@@ -584,7 +570,7 @@ function showBulkAssignModal() {
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
lt.modal.open('bulkAssignModal');
|
||||
openModalWithDismiss('bulkAssignModal', closeBulkAssignModal);
|
||||
setTimeout(() => { const inp = document.getElementById('bulkAssignUserInput'); if (inp) inp.focus(); }, 120);
|
||||
|
||||
lt.api.get('/api/get_users.php')
|
||||
@@ -682,7 +668,7 @@ function showBulkPriorityModal() {
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
lt.modal.open('bulkPriorityModal');
|
||||
openModalWithDismiss('bulkPriorityModal', closeBulkPriorityModal);
|
||||
}
|
||||
|
||||
function closeBulkPriorityModal() {
|
||||
@@ -777,6 +763,15 @@ function showBulkStatusModal() {
|
||||
<option value="">Select Status...</option>
|
||||
${(window.TICKET_STATUSES || ['Open','Pending','In Progress','Closed']).map(s => `<option value="${s}">${s}</option>`).join('')}
|
||||
</select>
|
||||
<label for="bulkStatusComment" style="margin-top:0.75rem">Reason / Comment:</label>
|
||||
<textarea id="bulkStatusComment" class="lt-input lt-w-full" rows="3"
|
||||
placeholder="Reason for the status change…"
|
||||
style="resize:vertical;font-family:inherit;font-size:0.8rem"
|
||||
aria-label="Reason for the bulk status change"></textarea>
|
||||
<p class="lt-text-xs lt-text-muted" style="margin-top:0.35rem">
|
||||
Required for transitions the Workflow Designer marks as needing a comment
|
||||
(e.g. closing a ticket). Posted as a comment on every ticket changed.
|
||||
</p>
|
||||
</div>
|
||||
<div class="lt-modal-footer">
|
||||
<button data-action="perform-bulk-status" class="lt-btn lt-btn-primary">UPDATE</button>
|
||||
@@ -787,7 +782,7 @@ function showBulkStatusModal() {
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
lt.modal.open('bulkStatusModal');
|
||||
openModalWithDismiss('bulkStatusModal', closeBulkStatusModal);
|
||||
}
|
||||
|
||||
function closeBulkStatusModal() {
|
||||
@@ -807,16 +802,19 @@ function performBulkStatusChange() {
|
||||
return;
|
||||
}
|
||||
|
||||
const commentEl = document.getElementById('bulkStatusComment');
|
||||
const comment = commentEl ? commentEl.value.trim() : '';
|
||||
|
||||
lt.api.post('/api/bulk_operation.php', {
|
||||
operation_type: 'bulk_status',
|
||||
ticket_ids: ticketIds,
|
||||
parameters: { status: status }
|
||||
parameters: { status: status, comment: comment }
|
||||
})
|
||||
.then(data => {
|
||||
closeBulkStatusModal();
|
||||
if (data.success) {
|
||||
if (data.failed > 0) {
|
||||
lt.toast.warning(`Status update: ${data.processed} succeeded, ${data.failed} failed`, 5000);
|
||||
lt.toast.warning(bulkResultMessage('Status update', data), 6000);
|
||||
} else {
|
||||
lt.toast.success(`Successfully updated status for ${data.processed} ticket(s)`, 4000);
|
||||
}
|
||||
@@ -826,10 +824,32 @@ function performBulkStatusChange() {
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
// Workflow needs a comment for at least one selected ticket — keep the
|
||||
// modal open so the reason can be typed in without re-selecting.
|
||||
if (error && error.data && error.data.requires_comment) {
|
||||
lt.toast.warning(error.data.error || 'A comment is required for this status change', 6000);
|
||||
const ta = document.getElementById('bulkStatusComment');
|
||||
if (ta) ta.focus();
|
||||
return;
|
||||
}
|
||||
closeBulkStatusModal();
|
||||
lt.toast.error('Bulk status change failed: ' + error.message, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a result message for a partially-successful bulk operation, surfacing the
|
||||
* per-ticket reasons (e.g. "transition not allowed") instead of a bare count.
|
||||
*/
|
||||
function bulkResultMessage(label, data) {
|
||||
let msg = `${label}: ${data.processed} succeeded, ${data.failed} failed`;
|
||||
if (Array.isArray(data.errors) && data.errors.length) {
|
||||
msg += ' — ' + data.errors.slice(0, 3).join('; ');
|
||||
if (data.errors.length > 3) msg += ` (+${data.errors.length - 3} more)`;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Bulk Delete
|
||||
function showBulkDeleteModal() {
|
||||
const ticketIds = getSelectedTicketIds();
|
||||
@@ -859,7 +879,7 @@ function showBulkDeleteModal() {
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
lt.modal.open('bulkDeleteModal');
|
||||
openModalWithDismiss('bulkDeleteModal', closeBulkDeleteModal);
|
||||
}
|
||||
|
||||
function closeBulkDeleteModal() {
|
||||
@@ -949,6 +969,22 @@ function showInputModal(title, label, placeholder = '', onSubmit, onCancel = nul
|
||||
input.addEventListener('keypress', (e) => { if (e.key === 'Enter') handleSubmit(); });
|
||||
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
|
||||
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
|
||||
// Backdrop click / Escape close the overlay via base.js's global handlers.
|
||||
modal.addEventListener('lt:modalclose', () => cleanup(onCancel));
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a dynamically-inserted modal and make sure it tears itself down however it
|
||||
* is dismissed. base.js handles backdrop clicks and Escape globally, so wiring
|
||||
* only the ✕/Cancel buttons leaves the overlay in the DOM — and the next open
|
||||
* inserts a second element with the same id, which then shadows the live one.
|
||||
*/
|
||||
function openModalWithDismiss(modalId, onDismiss) {
|
||||
lt.modal.open(modalId);
|
||||
const el = document.getElementById(modalId);
|
||||
// lt.modal.close() early-returns once .is-open is gone, so the close call
|
||||
// inside onDismiss cannot re-enter this listener.
|
||||
if (el) el.addEventListener('lt:modalclose', onDismiss);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
@@ -986,7 +1022,7 @@ function quickStatusChange(ticketId, currentStatus) {
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
lt.modal.open('quickStatusModal');
|
||||
openModalWithDismiss('quickStatusModal', closeQuickStatusModal);
|
||||
}
|
||||
|
||||
function closeQuickStatusModal() {
|
||||
@@ -1036,12 +1072,11 @@ function quickAssign(ticketId) {
|
||||
<div class="lt-modal-body">
|
||||
<p class="lt-mb-xs lt-text-muted lt-text-xs">Ticket #${lt.escHtml(String(ticketId))}</p>
|
||||
<label class="lt-label">Assign to:</label>
|
||||
<div class="lt-combobox" id="quickAssignCombobox">
|
||||
<div class="lt-combobox-input-wrap">
|
||||
<input type="text" class="lt-combobox-input" id="quickAssignInput"
|
||||
placeholder="Search users…" autocomplete="off" aria-label="Search users">
|
||||
</div>
|
||||
<ul class="lt-combobox-list" role="listbox" aria-hidden="true"></ul>
|
||||
<div class="lt-typeahead" id="quickAssignTypeahead" style="position:relative">
|
||||
<input type="text" class="lt-input lt-w-full" id="quickAssignInput"
|
||||
placeholder="Search users…" autocomplete="off" spellcheck="false"
|
||||
aria-label="Search users" aria-autocomplete="list">
|
||||
<div class="lt-typeahead-dropdown" id="quickAssignDropdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lt-modal-footer">
|
||||
@@ -1053,7 +1088,7 @@ function quickAssign(ticketId) {
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
lt.modal.open('quickAssignModal');
|
||||
openModalWithDismiss('quickAssignModal', closeQuickAssignModal);
|
||||
|
||||
lt.api.get('/api/get_users.php')
|
||||
.then(data => {
|
||||
@@ -1067,7 +1102,9 @@ function quickAssign(ticketId) {
|
||||
label: u.display_name || u.username
|
||||
}))
|
||||
];
|
||||
lt.combobox.init(input, items, {
|
||||
lt.typeahead.init(input, items, {
|
||||
minChars: 1,
|
||||
maxResults: 8,
|
||||
onSelect: function(item) { _quickAssignUserId = item.value || null; }
|
||||
});
|
||||
}
|
||||
@@ -1116,7 +1153,6 @@ function setViewMode(mode) {
|
||||
if (mode === 'card') {
|
||||
populateKanbanCards();
|
||||
}
|
||||
localStorage.setItem('ticketViewMode', mode);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+36
-11
@@ -354,6 +354,33 @@ window.renderMarkdownElements = renderMarkdownElements;
|
||||
// Rich Text Editor Toolbar Functions
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Replace textarea.value.substring(selStart, selEnd) with replacementText,
|
||||
* preserving the browser's native undo/redo stack via
|
||||
* document.execCommand('insertText', ...) -- the same mechanism real typing
|
||||
* uses -- instead of a direct .value assignment, which discards the entire
|
||||
* undo history. Falls back to a direct assignment (losing undo, matching the
|
||||
* old behavior) only if execCommand is unavailable or unsuccessful.
|
||||
*/
|
||||
function insertTextPreservingUndo(textarea, replacementText, selStart, selEnd) {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(selStart, selEnd);
|
||||
|
||||
let inserted = false;
|
||||
if (typeof document.execCommand === 'function') {
|
||||
try {
|
||||
inserted = document.execCommand('insertText', false, replacementText);
|
||||
} catch (e) {
|
||||
inserted = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!inserted) {
|
||||
const text = textarea.value;
|
||||
textarea.value = text.substring(0, selStart) + replacementText + text.substring(selEnd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert markdown formatting around selection
|
||||
*/
|
||||
@@ -363,16 +390,13 @@ function insertMarkdownFormat(textareaId, prefix, suffix) {
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = textarea.value;
|
||||
const selectedText = text.substring(start, end);
|
||||
const selectedText = textarea.value.substring(start, end);
|
||||
|
||||
// Insert formatting
|
||||
const newText = text.substring(0, start) + prefix + selectedText + suffix + text.substring(end);
|
||||
textarea.value = newText;
|
||||
insertTextPreservingUndo(textarea, prefix + selectedText + suffix, start, end);
|
||||
|
||||
// Set cursor position
|
||||
if (selectedText) {
|
||||
textarea.setSelectionRange(start + prefix.length, end + prefix.length);
|
||||
textarea.setSelectionRange(start + prefix.length, start + prefix.length + selectedText.length);
|
||||
} else {
|
||||
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
|
||||
}
|
||||
@@ -391,9 +415,10 @@ function insertMarkdownText(textareaId, text) {
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const value = textarea.value;
|
||||
|
||||
textarea.value = value.substring(0, start) + text + value.substring(start);
|
||||
// Matches the prior behavior: insert before the selection start without
|
||||
// deleting any currently-selected text (a collapsed replace range).
|
||||
insertTextPreservingUndo(textarea, text, start, start);
|
||||
textarea.setSelectionRange(start + text.length, start + text.length);
|
||||
textarea.focus();
|
||||
|
||||
@@ -453,7 +478,7 @@ function toolbarList(textareaId) {
|
||||
}
|
||||
|
||||
// Insert list marker at beginning of line
|
||||
textarea.value = text.substring(0, lineStart) + '- ' + text.substring(lineStart);
|
||||
insertTextPreservingUndo(textarea, '- ', lineStart, lineStart);
|
||||
textarea.setSelectionRange(start + 2, start + 2);
|
||||
textarea.focus();
|
||||
|
||||
@@ -474,7 +499,7 @@ function toolbarHeading(textareaId) {
|
||||
}
|
||||
|
||||
// Insert heading marker at beginning of line
|
||||
textarea.value = text.substring(0, lineStart) + '## ' + text.substring(lineStart);
|
||||
insertTextPreservingUndo(textarea, '## ', lineStart, lineStart);
|
||||
textarea.setSelectionRange(start + 3, start + 3);
|
||||
textarea.focus();
|
||||
|
||||
@@ -495,7 +520,7 @@ function toolbarQuote(textareaId) {
|
||||
}
|
||||
|
||||
// Insert quote marker at beginning of line
|
||||
textarea.value = text.substring(0, lineStart) + '> ' + text.substring(lineStart);
|
||||
insertTextPreservingUndo(textarea, '> ', lineStart, lineStart);
|
||||
textarea.setSelectionRange(start + 2, start + 2);
|
||||
textarea.focus();
|
||||
|
||||
|
||||
+48
-8
@@ -183,15 +183,35 @@ function toggleEditMode() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute avatar color class from display name (mirrors PHP crc32 % 4 logic)
|
||||
* CRC-32 (IEEE 802.3 / zlib polynomial), matching PHP's crc32(). Operates on
|
||||
* the UTF-8 byte sequence, same as PHP, so results agree for non-ASCII names.
|
||||
*/
|
||||
function crc32(str) {
|
||||
var bytes = unescape(encodeURIComponent(str));
|
||||
var table = crc32._table || (crc32._table = (function () {
|
||||
var t = [];
|
||||
for (var n = 0; n < 256; n++) {
|
||||
var c = n;
|
||||
for (var k = 0; k < 8; k++) {
|
||||
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
}
|
||||
t[n] = c;
|
||||
}
|
||||
return t;
|
||||
})());
|
||||
var crc = -1;
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
crc = (crc >>> 8) ^ table[(crc ^ bytes.charCodeAt(i)) & 0xFF];
|
||||
}
|
||||
return (crc ^ -1) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute avatar color class from display name (mirrors PHP's crc32 % 4 logic)
|
||||
*/
|
||||
function avatarColorClass(displayName) {
|
||||
var colors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
||||
var h = 0;
|
||||
for (var i = 0; i < displayName.length; i++) {
|
||||
h = ((h << 5) - h + displayName.charCodeAt(i)) | 0;
|
||||
}
|
||||
return colors[Math.abs(h) % 4];
|
||||
return colors[crc32(displayName) % 4];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -285,6 +305,14 @@ function addComment() {
|
||||
const nc = document.getElementById('newComment');
|
||||
if (nc) nc.value = '';
|
||||
|
||||
// Clear the live preview — clearing the textarea programmatically
|
||||
// does not fire 'input', so updatePreview() never runs
|
||||
const previewDiv = document.getElementById('markdownPreview');
|
||||
if (previewDiv) {
|
||||
previewDiv.innerHTML = '';
|
||||
previewDiv.classList.add('is-hidden');
|
||||
}
|
||||
|
||||
// Format the comment text for display
|
||||
let displayText;
|
||||
if (isMarkdownEnabled) {
|
||||
@@ -521,7 +549,19 @@ function updateTicketStatus() {
|
||||
`);
|
||||
const modal = document.getElementById(modalId);
|
||||
lt.modal.open(modalId);
|
||||
const cleanup = (ok) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (!ok) statusSelect.selectedIndex = 0; };
|
||||
let settled = false;
|
||||
const cleanup = (ok) => {
|
||||
if (settled) return; // lt.modal.close() below re-enters via lt:modalclose
|
||||
settled = true;
|
||||
lt.modal.close(modalId);
|
||||
setTimeout(() => modal.remove(), 300);
|
||||
if (!ok) statusSelect.selectedIndex = 0;
|
||||
};
|
||||
// Backdrop click and Escape close the overlay through base.js's global
|
||||
// handlers. Without this the dropdown kept displaying the new status
|
||||
// while the server was never called, so the ticket looked closed until
|
||||
// a reload revealed it was still open.
|
||||
modal.addEventListener('lt:modalclose', () => cleanup(false));
|
||||
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(false));
|
||||
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(false));
|
||||
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => {
|
||||
@@ -1028,7 +1068,7 @@ function renderAttachments(attachments) {
|
||||
});
|
||||
const uploadDate = `<span class="ts-cell" data-ts="${lt.escHtml(att.uploaded_at)}" title="${lt.escHtml(uploadDateFormatted)}">${lt.time.ago(att.uploaded_at)}</span>`;
|
||||
|
||||
const isImage = /\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(att.original_filename);
|
||||
const isImage = /^image\//i.test(att.mime_type || '');
|
||||
const imgUrl = `/api/download_attachment.php?id=${att.attachment_id}&inline=1`;
|
||||
const iconHtml = isImage
|
||||
? `<a href="${imgUrl}" class="lt-lightbox-trigger" data-lightbox="ticket-attachments" title="${lt.escHtml(att.original_filename)}">
|
||||
|
||||
@@ -115,6 +115,8 @@ $GLOBALS['config'] = [
|
||||
|
||||
// File upload settings
|
||||
'MAX_UPLOAD_SIZE' => 10485760, // 10MB in bytes
|
||||
'MAX_ATTACHMENTS_PER_TICKET' => 50,
|
||||
'MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET' => 104857600, // 100MB in bytes
|
||||
'ALLOWED_FILE_TYPES' => [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
|
||||
@@ -25,4 +25,11 @@ return [
|
||||
'fileinfo', // api/upload_attachment.php — MIME validation
|
||||
'json', // request/response encoding (bundled, but assert anyway)
|
||||
],
|
||||
|
||||
// Sanity-check thresholds (warnings, not hard failures). A host with a low
|
||||
// default memory_limit passes a bare extension/version check cleanly and
|
||||
// only surfaces as a mysterious failure under real load — a large CSV
|
||||
// export, an oversized dashboard query on a big install.
|
||||
'min_memory_limit_mb' => 256,
|
||||
'min_max_execution_time' => 30, // seconds; 0 (unlimited) always passes
|
||||
];
|
||||
|
||||
@@ -72,6 +72,9 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ticket creation is a write — a read-only key must be rejected with 403.
|
||||
$apiKeyAuth->requireScope('read_write');
|
||||
|
||||
$userId = $systemUser['user_id'];
|
||||
|
||||
// Parse input regardless of content-type header
|
||||
|
||||
+36
-1
@@ -121,6 +121,33 @@ class CacheHelper
|
||||
return $written;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current invalidation epoch for a prefix (0 if never bumped).
|
||||
* Used by remember() to detect an invalidation that happened while a
|
||||
* cache-miss recomputation was in flight.
|
||||
*/
|
||||
private static function getEpoch(string $prefix): int
|
||||
{
|
||||
$safePrefix = preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix);
|
||||
$file = self::getCacheDir() . '/' . $safePrefix . '.epoch';
|
||||
$val = @file_get_contents($file);
|
||||
return $val !== false ? (int)$val : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bump a prefix's invalidation epoch. Called whenever anything under the
|
||||
* prefix is invalidated.
|
||||
*/
|
||||
private static function bumpEpoch(string $prefix): void
|
||||
{
|
||||
$safePrefix = preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix);
|
||||
$file = self::getCacheDir() . '/' . $safePrefix . '.epoch';
|
||||
$next = self::getEpoch($prefix) + 1;
|
||||
if (@file_put_contents($file, (string)$next, LOCK_EX) !== false) {
|
||||
@chmod($file, 0600);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cached data
|
||||
*
|
||||
@@ -130,6 +157,8 @@ class CacheHelper
|
||||
*/
|
||||
public static function delete(string $prefix, $identifier = null): bool
|
||||
{
|
||||
self::bumpEpoch($prefix);
|
||||
|
||||
if ($identifier !== null) {
|
||||
$key = self::makeKey($prefix, $identifier);
|
||||
unset(self::$memoryCache[$key]);
|
||||
@@ -192,8 +221,14 @@ class CacheHelper
|
||||
$data = self::get($prefix, $identifier, $ttl);
|
||||
|
||||
if ($data === null) {
|
||||
// Snapshot the epoch before running the (possibly slow) callback so
|
||||
// a concurrent invalidation mid-computation can be detected below —
|
||||
// otherwise this request's stale pre-invalidation result could
|
||||
// overwrite a newer request's fresher write, extending staleness by
|
||||
// up to another full TTL.
|
||||
$epochBefore = self::getEpoch($prefix);
|
||||
$data = $callback();
|
||||
if ($data !== null) {
|
||||
if ($data !== null && self::getEpoch($prefix) === $epochBefore) {
|
||||
self::set($prefix, $identifier, $data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* OutputHelper - Consistent output escaping utilities
|
||||
*
|
||||
* Provides secure HTML escaping functions to prevent XSS attacks.
|
||||
* Use these functions when outputting user-controlled data.
|
||||
*/
|
||||
class OutputHelper
|
||||
{
|
||||
/**
|
||||
* Escape string for HTML output
|
||||
*
|
||||
* Use for text content inside HTML elements.
|
||||
* Example: <p><?= OutputHelper::h($userInput) ?></p>
|
||||
*
|
||||
* @param string|null $string The string to escape
|
||||
* @param int $flags htmlspecialchars flags (default: ENT_QUOTES | ENT_HTML5)
|
||||
* @return string Escaped string
|
||||
*/
|
||||
public static function h(?string $string, int $flags = ENT_QUOTES | ENT_HTML5): string
|
||||
{
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
return htmlspecialchars($string, $flags, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape string for HTML attribute context
|
||||
*
|
||||
* Use for values inside HTML attributes.
|
||||
* Example: <input value="<?= OutputHelper::attr($userInput) ?>">
|
||||
*
|
||||
* @param string|null $string The string to escape
|
||||
* @return string Escaped string
|
||||
*/
|
||||
public static function attr(?string $string): string
|
||||
{
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
// More aggressive escaping for attribute context
|
||||
return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5 | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode data as JSON for JavaScript context
|
||||
*
|
||||
* Use when embedding data in JavaScript.
|
||||
* Example: <script>const data = <?= OutputHelper::json($data) ?>;</script>
|
||||
*
|
||||
* @param mixed $data The data to encode
|
||||
* @param int $flags json_encode flags
|
||||
* @return string JSON encoded string (safe for script context)
|
||||
*/
|
||||
public static function json($data, int $flags = 0): string
|
||||
{
|
||||
// Use HEX encoding for safety in HTML context
|
||||
$safeFlags = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | $flags;
|
||||
return json_encode($data, $safeFlags);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL encode a string
|
||||
*
|
||||
* Use for values in URL query strings.
|
||||
* Example: <a href="/search?q=<?= OutputHelper::url($query) ?>">
|
||||
*
|
||||
* @param string|null $string The string to encode
|
||||
* @return string URL encoded string
|
||||
*/
|
||||
public static function url(?string $string): string
|
||||
{
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
return rawurlencode($string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape for CSS context
|
||||
*
|
||||
* Use for values in inline CSS.
|
||||
* Example: <div style="color: <?= OutputHelper::css($color) ?>;">
|
||||
*
|
||||
* @param string|null $string The string to escape
|
||||
* @return string Escaped string (only allows safe characters)
|
||||
*/
|
||||
public static function css(?string $string): string
|
||||
{
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
// Only allow alphanumeric, hyphens, underscores, spaces, and common CSS values
|
||||
if (!preg_match('/^[a-zA-Z0-9_\-\s#.,()%]+$/', $string)) {
|
||||
return '';
|
||||
}
|
||||
return $string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number safely
|
||||
*
|
||||
* Ensures output is always a valid number.
|
||||
*
|
||||
* @param mixed $number The number to format
|
||||
* @param int $decimals Number of decimal places
|
||||
* @return string Formatted number
|
||||
*/
|
||||
public static function number($number, int $decimals = 0): string
|
||||
{
|
||||
return number_format((float)$number, $decimals, '.', ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an integer safely
|
||||
*
|
||||
* @param mixed $value The value to format
|
||||
* @return int Integer value
|
||||
*/
|
||||
public static function int($value): int
|
||||
{
|
||||
return (int)$value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate string with ellipsis
|
||||
*
|
||||
* @param string|null $string The string to truncate
|
||||
* @param int $length Maximum length
|
||||
* @param string $suffix Suffix to add if truncated
|
||||
* @return string Truncated and escaped string
|
||||
*/
|
||||
public static function truncate(?string $string, int $length = 100, string $suffix = '...'): string
|
||||
{
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (mb_strlen($string, 'UTF-8') <= $length) {
|
||||
return self::h($string);
|
||||
}
|
||||
|
||||
return self::h(mb_substr($string, 0, $length, 'UTF-8')) . self::h($suffix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date safely
|
||||
*
|
||||
* @param string|int|null $date Date string, timestamp, or null
|
||||
* @param string $format PHP date format
|
||||
* @return string Formatted date
|
||||
*/
|
||||
public static function date($date, string $format = 'Y-m-d H:i:s'): string
|
||||
{
|
||||
if ($date === null || $date === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (is_numeric($date)) {
|
||||
return date($format, (int)$date);
|
||||
}
|
||||
|
||||
$timestamp = strtotime($date);
|
||||
if ($timestamp === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return date($format, $timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is safe for use as a CSS class name
|
||||
*
|
||||
* @param string $class The class name to validate
|
||||
* @return bool True if safe
|
||||
*/
|
||||
public static function isValidCssClass(string $class): bool
|
||||
{
|
||||
return preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $class) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize CSS class name(s)
|
||||
*
|
||||
* @param string|null $classes Space-separated class names
|
||||
* @return string Sanitized class names
|
||||
*/
|
||||
public static function cssClass(?string $classes): string
|
||||
{
|
||||
if ($classes === null || $classes === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$classList = explode(' ', $classes);
|
||||
$validClasses = array_filter($classList, [self::class, 'isValidCssClass']);
|
||||
|
||||
return implode(' ', $validClasses);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand function for HTML escaping
|
||||
*
|
||||
* @param string|null $string The string to escape
|
||||
* @return string Escaped string
|
||||
*/
|
||||
function h(?string $string): string
|
||||
{
|
||||
return OutputHelper::h($string);
|
||||
}
|
||||
@@ -331,7 +331,15 @@ switch (true) {
|
||||
requireAdmin($currentUser);
|
||||
require_once 'models/ApiKeyModel.php';
|
||||
$apiKeyModel = new ApiKeyModel($conn);
|
||||
$apiKeys = $apiKeyModel->getAllKeys();
|
||||
|
||||
// Validate the requested page to a positive int (default 1)
|
||||
$apiKeysPage = isset($_GET['page']) ? (int)$_GET['page'] : 1;
|
||||
if ($apiKeysPage < 1) {
|
||||
$apiKeysPage = 1;
|
||||
}
|
||||
$apiKeysPerPage = 20;
|
||||
|
||||
$apiKeys = $apiKeyModel->getAllKeys($apiKeysPage, $apiKeysPerPage);
|
||||
include 'views/admin/ApiKeysView.php';
|
||||
break;
|
||||
|
||||
@@ -383,13 +391,16 @@ switch (true) {
|
||||
LEFT JOIN (
|
||||
SELECT user_id, MAX(created_at) as last_activity
|
||||
FROM audit_log
|
||||
WHERE DATE(created_at) BETWEEN ? AND ?
|
||||
GROUP BY user_id
|
||||
) al ON u.user_id = al.user_id
|
||||
ORDER BY tickets_created DESC, tickets_resolved DESC";
|
||||
|
||||
$stmt = $conn->prepare($sql);
|
||||
$stmt->bind_param(
|
||||
'ssssssss',
|
||||
'ssssssssss',
|
||||
$dateRange['from'],
|
||||
$dateRange['to'],
|
||||
$dateRange['from'],
|
||||
$dateRange['to'],
|
||||
$dateRange['from'],
|
||||
|
||||
@@ -13,6 +13,14 @@ class ApiKeyAuth
|
||||
private $userModel;
|
||||
private $conn;
|
||||
|
||||
/**
|
||||
* Context of the API key validated by the most recent authenticate()/
|
||||
* verifyOptional() call, or null if none succeeded.
|
||||
*
|
||||
* @var array|null
|
||||
*/
|
||||
private $keyContext = null;
|
||||
|
||||
public function __construct($conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
@@ -20,6 +28,57 @@ class ApiKeyAuth
|
||||
$this->userModel = new UserModel($conn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the validated key's context for later scope/attribution checks.
|
||||
*
|
||||
* @param array $keyData Row returned by ApiKeyModel::validateKey()
|
||||
*/
|
||||
private function setKeyContext(array $keyData)
|
||||
{
|
||||
$this->keyContext = [
|
||||
'scope' => $keyData['scope'] ?? 'read_write',
|
||||
'key_name' => $keyData['key_name'] ?? null,
|
||||
'created_by' => $keyData['created_by'] ?? null,
|
||||
'api_key_id' => $keyData['api_key_id'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the context of the authenticated API key.
|
||||
*
|
||||
* @return array|null ['scope', 'key_name', 'created_by', 'api_key_id'] or null
|
||||
*/
|
||||
public function getKeyContext(): ?array
|
||||
{
|
||||
return $this->keyContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce that the authenticated key satisfies the required scope.
|
||||
*
|
||||
* A 'read' key satisfies only 'read'; a 'read_write' key satisfies both
|
||||
* 'read' and 'read_write'. On failure a 403 JSON error is sent and the
|
||||
* script exits.
|
||||
*
|
||||
* @param string $needed Required scope ('read' or 'read_write')
|
||||
*/
|
||||
public function requireScope(string $needed): void
|
||||
{
|
||||
$current = $this->keyContext['scope'] ?? null;
|
||||
|
||||
// 'read_write' can do anything; 'read' can only satisfy a 'read' need.
|
||||
$ok = ($current === 'read_write')
|
||||
|| ($current === 'read' && $needed === 'read');
|
||||
|
||||
if (!$ok) {
|
||||
$this->sendForbidden(
|
||||
'API key scope "' . ($current ?? 'none') . '" is insufficient; "'
|
||||
. $needed . '" is required'
|
||||
);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using API key from Authorization header
|
||||
*
|
||||
@@ -52,6 +111,9 @@ class ApiKeyAuth
|
||||
exit;
|
||||
}
|
||||
|
||||
// Record key context (scope / attribution) for callers to inspect.
|
||||
$this->setKeyContext($keyData);
|
||||
|
||||
// Get system user (or the user who created the key)
|
||||
$user = $this->userModel->getSystemUser();
|
||||
|
||||
@@ -113,6 +175,22 @@ class ApiKeyAuth
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send 403 Forbidden response (e.g. insufficient scope)
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
private function sendForbidden($message)
|
||||
{
|
||||
header('HTTP/1.1 403 Forbidden');
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Forbidden',
|
||||
'message' => $message
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify API key without throwing errors (for optional auth)
|
||||
*
|
||||
@@ -137,6 +215,9 @@ class ApiKeyAuth
|
||||
return null;
|
||||
}
|
||||
|
||||
// Record key context (scope / attribution) for callers to inspect.
|
||||
$this->setKeyContext($keyData);
|
||||
|
||||
$user = $this->userModel->getSystemUser();
|
||||
|
||||
if ($user) {
|
||||
|
||||
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `api_keys` (
|
||||
`key_hash` varchar(255) NOT NULL,
|
||||
`key_prefix` varchar(20) NOT NULL,
|
||||
`is_active` tinyint(1) DEFAULT 1,
|
||||
`scope` enum('read','read_write') NOT NULL DEFAULT 'read_write',
|
||||
`created_by` int(11) DEFAULT NULL,
|
||||
`last_used` timestamp NULL DEFAULT NULL,
|
||||
`expires_at` timestamp NULL DEFAULT NULL,
|
||||
@@ -56,9 +57,10 @@ CREATE TABLE IF NOT EXISTS `bulk_operations` (
|
||||
`operation_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`operation_type` varchar(50) NOT NULL,
|
||||
`ticket_ids` text NOT NULL,
|
||||
`performed_by` int(11) NOT NULL,
|
||||
`performed_by` int(11) DEFAULT NULL,
|
||||
`parameters` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`parameters`)),
|
||||
`status` varchar(20) DEFAULT 'pending',
|
||||
-- 32, not 20: 'completed_with_errors' is 21 chars (see 001_widen_bulk_operations_status.sql)
|
||||
`status` varchar(32) DEFAULT 'pending',
|
||||
`total_tickets` int(11) DEFAULT NULL,
|
||||
`processed_tickets` int(11) DEFAULT 0,
|
||||
`failed_tickets` int(11) DEFAULT 0,
|
||||
@@ -67,7 +69,7 @@ CREATE TABLE IF NOT EXISTS `bulk_operations` (
|
||||
PRIMARY KEY (`operation_id`),
|
||||
KEY `idx_performed_by` (`performed_by`),
|
||||
KEY `idx_created_at` (`created_at`),
|
||||
CONSTRAINT `bulk_operations_ibfk_1` FOREIGN KEY (`performed_by`) REFERENCES `users` (`user_id`)
|
||||
CONSTRAINT `bulk_operations_ibfk_1` FOREIGN KEY (`performed_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- ============ custom_field_definitions ============
|
||||
@@ -153,7 +155,7 @@ CREATE TABLE IF NOT EXISTS `saved_filters` (
|
||||
UNIQUE KEY `unique_user_filter_name` (`user_id`,`filter_name`),
|
||||
KEY `idx_user_filters` (`user_id`,`is_default`),
|
||||
CONSTRAINT `saved_filters_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- ============ status_transitions ============
|
||||
CREATE TABLE IF NOT EXISTS `status_transitions` (
|
||||
@@ -183,7 +185,7 @@ CREATE TABLE IF NOT EXISTS `ticket_attachments` (
|
||||
KEY `idx_attachments_ticket` (`ticket_id`),
|
||||
KEY `idx_attachments_uploaded_by` (`uploaded_by`),
|
||||
CONSTRAINT `ticket_attachments_ibfk_1` FOREIGN KEY (`uploaded_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- ============ ticket_comments ============
|
||||
CREATE TABLE IF NOT EXISTS `ticket_comments` (
|
||||
@@ -236,7 +238,7 @@ CREATE TABLE IF NOT EXISTS `ticket_templates` (
|
||||
PRIMARY KEY (`template_id`),
|
||||
KEY `created_by` (`created_by`),
|
||||
KEY `idx_template_name` (`template_name`),
|
||||
CONSTRAINT `ticket_templates_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`)
|
||||
CONSTRAINT `ticket_templates_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- ============ ticket_watchers ============
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Widen bulk_operations.status
|
||||
--
|
||||
-- The code writes 'completed_with_errors' (21 chars) when a bulk operation
|
||||
-- finishes with per-ticket failures, but the column was varchar(20), so the
|
||||
-- write failed with "Data too long for column 'status'". This was unreachable
|
||||
-- while bulk status changes forced every transition through; now that they
|
||||
-- honour the Workflow Designer, partial failures are a normal outcome.
|
||||
--
|
||||
-- Safe to re-run.
|
||||
|
||||
ALTER TABLE `bulk_operations`
|
||||
MODIFY COLUMN `status` varchar(32) DEFAULT 'pending';
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Fix collation inconsistency on saved_filters and ticket_attachments
|
||||
--
|
||||
-- README.md Developer Notes #12: "Database collation: Use
|
||||
-- utf8mb4_general_ci (not unicode_ci) for new tables." These two tables
|
||||
-- were created with utf8mb4_unicode_ci instead, inconsistent with every
|
||||
-- other table in the schema. Mixed collations don't break anything by
|
||||
-- themselves, but any future query joining/comparing these columns
|
||||
-- against general_ci columns needs explicit COLLATE casts or hits
|
||||
-- "Illegal mix of collations" errors.
|
||||
--
|
||||
-- Safe to re-run.
|
||||
|
||||
ALTER TABLE `saved_filters`
|
||||
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||
|
||||
-- saved_filters.filter_criteria is pinned to utf8mb4_bin (for the
|
||||
-- json_valid() CHECK constraint) — restore that after the table-wide
|
||||
-- CONVERT TO above, which resets it to general_ci. MariaDB drops the
|
||||
-- inline CHECK when the column is MODIFYed, so re-add it explicitly.
|
||||
ALTER TABLE `saved_filters`
|
||||
MODIFY COLUMN `filter_criteria` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL;
|
||||
|
||||
ALTER TABLE `saved_filters`
|
||||
DROP CONSTRAINT IF EXISTS `saved_filters_filter_criteria_json`;
|
||||
|
||||
ALTER TABLE `saved_filters`
|
||||
ADD CONSTRAINT `saved_filters_filter_criteria_json` CHECK (json_valid(`filter_criteria`));
|
||||
|
||||
ALTER TABLE `ticket_attachments`
|
||||
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Fix inconsistent FK ON DELETE behavior on bulk_operations.performed_by and
|
||||
-- ticket_templates.created_by
|
||||
--
|
||||
-- Every other user-reference FK in the schema (tickets.created_by/updated_by/
|
||||
-- assigned_to, ticket_attachments.uploaded_by, ticket_dependencies.created_by,
|
||||
-- recurring_tickets.created_by/assigned_to, api_keys.created_by, etc.) uses
|
||||
-- ON DELETE SET NULL. These two had no ON DELETE clause at all, which
|
||||
-- defaults to RESTRICT — so deleting a user who ever ran a bulk operation or
|
||||
-- created a template hard-fails at the DB level instead of nulling the
|
||||
-- reference, breaking the pattern used everywhere else and potentially
|
||||
-- blocking legitimate user offboarding/cleanup.
|
||||
--
|
||||
-- bulk_operations.performed_by is NOT NULL today; it must become nullable to
|
||||
-- support SET NULL, matching how every other SET NULL column in the schema
|
||||
-- is defined.
|
||||
--
|
||||
-- Safe to re-run.
|
||||
|
||||
ALTER TABLE `bulk_operations`
|
||||
MODIFY COLUMN `performed_by` int(11) DEFAULT NULL;
|
||||
|
||||
ALTER TABLE `bulk_operations`
|
||||
DROP FOREIGN KEY IF EXISTS `bulk_operations_ibfk_1`;
|
||||
|
||||
ALTER TABLE `bulk_operations`
|
||||
ADD CONSTRAINT `bulk_operations_ibfk_1` FOREIGN KEY (`performed_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE `ticket_templates`
|
||||
DROP FOREIGN KEY IF EXISTS `ticket_templates_ibfk_1`;
|
||||
|
||||
ALTER TABLE `ticket_templates`
|
||||
ADD CONSTRAINT `ticket_templates_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL;
|
||||
+56
-9
@@ -18,10 +18,19 @@ class ApiKeyModel
|
||||
* @param string $keyName Descriptive name for the key
|
||||
* @param int $createdBy User ID who created the key
|
||||
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
|
||||
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error'
|
||||
* @param string $scope Access scope: 'read' or 'read_write' (default 'read_write')
|
||||
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'scope', 'error'
|
||||
*/
|
||||
public function createKey($keyName, $createdBy, $expiresInDays = null)
|
||||
public function createKey($keyName, $createdBy, $expiresInDays = null, $scope = 'read_write')
|
||||
{
|
||||
// Validate the requested scope — only the two known values are allowed
|
||||
if (!in_array($scope, ['read', 'read_write'], true)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => "Invalid scope: must be 'read' or 'read_write'"
|
||||
];
|
||||
}
|
||||
|
||||
// Generate random API key (32 bytes = 64 hex characters)
|
||||
$apiKey = bin2hex(random_bytes(32));
|
||||
|
||||
@@ -39,9 +48,10 @@ class ApiKeyModel
|
||||
|
||||
// Insert API key into database
|
||||
$stmt = $this->conn->prepare(
|
||||
"INSERT INTO api_keys (key_name, key_hash, key_prefix, created_by, expires_at) VALUES (?, ?, ?, ?, ?)"
|
||||
"INSERT INTO api_keys (key_name, key_hash, key_prefix, scope, created_by, expires_at) "
|
||||
. "VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->bind_param("sssis", $keyName, $keyHash, $keyPrefix, $createdBy, $expiresAt);
|
||||
$stmt->bind_param("ssssis", $keyName, $keyHash, $keyPrefix, $scope, $createdBy, $expiresAt);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$keyId = $this->conn->insert_id;
|
||||
@@ -52,6 +62,7 @@ class ApiKeyModel
|
||||
'api_key' => $apiKey, // Return plaintext key ONCE
|
||||
'key_prefix' => $keyPrefix,
|
||||
'key_id' => $keyId,
|
||||
'scope' => $scope,
|
||||
'expires_at' => $expiresAt
|
||||
];
|
||||
} else {
|
||||
@@ -96,6 +107,13 @@ class ApiKeyModel
|
||||
$keyData = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
// Ensure a scope is always present. On an un-migrated database the column
|
||||
// does not exist yet (or is null), in which case we treat the key as
|
||||
// full-access so existing integrations keep working.
|
||||
if (!isset($keyData['scope']) || $keyData['scope'] === null || $keyData['scope'] === '') {
|
||||
$keyData['scope'] = 'read_write';
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if ($keyData['expires_at'] !== null) {
|
||||
$expiresAt = strtotime($keyData['expires_at']);
|
||||
@@ -156,18 +174,41 @@ class ApiKeyModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all API keys (for admin panel)
|
||||
* Get a page of API keys (for admin panel)
|
||||
*
|
||||
* @return array Array of API key records (without hashes)
|
||||
* Active keys are listed first, then newest first within each group.
|
||||
*
|
||||
* @param int $page 1-based page number
|
||||
* @param int $perPage Rows per page
|
||||
* @return array ['keys' => array, 'total' => int, 'page' => int, 'perPage' => int]
|
||||
*/
|
||||
public function getAllKeys()
|
||||
public function getAllKeys($page = 1, $perPage = 20)
|
||||
{
|
||||
// Normalise pagination inputs
|
||||
$page = max(1, (int)$page);
|
||||
$perPage = (int)$perPage;
|
||||
if ($perPage < 1) {
|
||||
$perPage = 20;
|
||||
}
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
// Total count for pagination controls
|
||||
$total = 0;
|
||||
$countResult = $this->conn->query("SELECT COUNT(*) AS total FROM api_keys");
|
||||
if ($countResult) {
|
||||
$countRow = $countResult->fetch_assoc();
|
||||
$total = (int)($countRow['total'] ?? 0);
|
||||
$countResult->free();
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT ak.*, u.username, u.display_name
|
||||
FROM api_keys ak
|
||||
LEFT JOIN users u ON ak.created_by = u.user_id
|
||||
ORDER BY ak.created_at DESC"
|
||||
ORDER BY ak.is_active DESC, ak.created_at DESC
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
$stmt->bind_param("ii", $perPage, $offset);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
@@ -179,7 +220,13 @@ class ApiKeyModel
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $keys;
|
||||
|
||||
return [
|
||||
'keys' => $keys,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'perPage' => $perPage
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -309,17 +309,28 @@ class AuditLogModel
|
||||
* @param int $daysToKeep Number of days of logs to keep
|
||||
* @return int Number of deleted records
|
||||
*/
|
||||
public function deleteOldLogs($daysToKeep = 90)
|
||||
public function deleteOldLogs($daysToKeep = 90, $batchSize = 1000)
|
||||
{
|
||||
// Batched to bound how long each statement holds row locks — an
|
||||
// unbounded single DELETE on a large backlog (e.g. the first run after
|
||||
// enabling/changing retention, or after the cron silently missed runs)
|
||||
// would otherwise contend with the frequent concurrent INSERTs the
|
||||
// audit log receives from live traffic.
|
||||
$stmt = $this->conn->prepare(
|
||||
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
|
||||
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY audit_id LIMIT ?"
|
||||
);
|
||||
$stmt->bind_param("i", $daysToKeep);
|
||||
$stmt->execute();
|
||||
$affectedRows = $stmt->affected_rows;
|
||||
$stmt->bind_param("ii", $daysToKeep, $batchSize);
|
||||
|
||||
$totalDeleted = 0;
|
||||
do {
|
||||
$stmt->execute();
|
||||
$affected = $stmt->affected_rows;
|
||||
$totalDeleted += $affected;
|
||||
} while ($affected > 0);
|
||||
|
||||
$stmt->close();
|
||||
|
||||
return $affectedRows;
|
||||
return $totalDeleted;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+139
-13
@@ -7,11 +7,47 @@ class BulkOperationsModel
|
||||
{
|
||||
private $conn;
|
||||
|
||||
/** @var WorkflowModel|null Lazily created; only needed by status-changing operations */
|
||||
private $workflowModel = null;
|
||||
|
||||
/** @var CommentModel|null Lazily created; only needed when a status change carries a comment */
|
||||
private $commentModel = null;
|
||||
|
||||
/** @var array<int,string> user_id → display name, resolved once per request */
|
||||
private $userNames = [];
|
||||
|
||||
public function __construct($conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow model, created on first use.
|
||||
*/
|
||||
private function workflow(): WorkflowModel
|
||||
{
|
||||
if ($this->workflowModel === null) {
|
||||
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||
$this->workflowModel = new WorkflowModel($this->conn);
|
||||
}
|
||||
return $this->workflowModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* The status a bulk operation is trying to move tickets into, or null for
|
||||
* operations that don't change status.
|
||||
*/
|
||||
private function targetStatusFor(string $operationType, array $parameters): ?string
|
||||
{
|
||||
if ($operationType === 'bulk_close') {
|
||||
return 'Closed';
|
||||
}
|
||||
if ($operationType === 'bulk_status') {
|
||||
return isset($parameters['status']) ? (string)$parameters['status'] : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new bulk operation record
|
||||
*
|
||||
@@ -100,6 +136,30 @@ class BulkOperationsModel
|
||||
// Batch load all tickets in one query to eliminate N+1 problem
|
||||
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
|
||||
|
||||
// Status-changing operations honour the Workflow Designer. If any ticket in
|
||||
// the selection needs a comment for its transition, reject the whole batch
|
||||
// before mutating anything so the client can collect one — a partially
|
||||
// applied batch is worse than none.
|
||||
$targetStatus = $this->targetStatusFor($operation['operation_type'], is_array($parameters) ? $parameters : []);
|
||||
$bulkComment = trim((string)($parameters['comment'] ?? ''));
|
||||
if ($targetStatus !== null && $bulkComment === '') {
|
||||
foreach ($ticketIds as $tid) {
|
||||
$t = $ticketsById[trim($tid)] ?? null;
|
||||
if (!$t || $t['status'] === $targetStatus) {
|
||||
continue;
|
||||
}
|
||||
if ($this->workflow()->transitionRequiresComment($t['status'], $targetStatus)) {
|
||||
return [
|
||||
'processed' => 0,
|
||||
'failed' => count($ticketIds),
|
||||
'error' => 'A comment is required to change status from '
|
||||
. $t['status'] . ' → ' . $targetStatus,
|
||||
'requires_comment' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start transaction for data consistency
|
||||
$this->conn->begin_transaction();
|
||||
|
||||
@@ -113,11 +173,32 @@ class BulkOperationsModel
|
||||
$success = false;
|
||||
|
||||
try {
|
||||
// NOTE: bulk_status / bulk_close intentionally do NOT run
|
||||
// WorkflowModel::isTransitionAllowed(). Bulk operations are an
|
||||
// admin-only escape hatch for forcing ticket states (e.g. mass
|
||||
// re-opening), so they bypass the workflow transition rules that
|
||||
// the single-ticket update path enforces. This is by design.
|
||||
// bulk_status / bulk_close enforce the same Workflow Designer
|
||||
// rules as the single-ticket path: a transition the designer
|
||||
// doesn't define is refused, and requires_comment is honoured
|
||||
// (checked up front, above). requires_admin is satisfied because
|
||||
// api/bulk_operation.php already gates the endpoint on admin.
|
||||
if ($targetStatus !== null) {
|
||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||
if ($currentTicket && $currentTicket['status'] === $targetStatus) {
|
||||
// Already in the requested state — nothing to do, and
|
||||
// reporting a no-op as a failure would just confuse.
|
||||
$processed++;
|
||||
continue;
|
||||
}
|
||||
$allowed = $currentTicket === null || $this->workflow()->isTransitionAllowed(
|
||||
$currentTicket['status'],
|
||||
$targetStatus,
|
||||
true
|
||||
);
|
||||
if (!$allowed) {
|
||||
$failed++;
|
||||
$errors[] = "Ticket $ticketId: transition not allowed ("
|
||||
. $currentTicket['status'] . ' → ' . $targetStatus . ')';
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
switch ($operation['operation_type']) {
|
||||
case 'bulk_close':
|
||||
// Get current ticket from pre-loaded batch
|
||||
@@ -232,6 +313,12 @@ class BulkOperationsModel
|
||||
}
|
||||
|
||||
if ($success) {
|
||||
// Persist the status-change reason as a real comment, so a
|
||||
// bulk close is as auditable on the ticket as a single close
|
||||
// (where the client posts the comment before updating).
|
||||
if ($targetStatus !== null && $bulkComment !== '') {
|
||||
$this->postBulkComment($ticketId, (int)$operation['performed_by'], $bulkComment);
|
||||
}
|
||||
$processed++;
|
||||
} else {
|
||||
$failed++;
|
||||
@@ -290,14 +377,22 @@ class BulkOperationsModel
|
||||
];
|
||||
}
|
||||
|
||||
// Update operation status
|
||||
$status = $failed > 0 ? 'completed_with_errors' : 'completed';
|
||||
$sql = "UPDATE bulk_operations SET status = ?, processed_tickets = ?, failed_tickets = ?,
|
||||
completed_at = NOW() WHERE operation_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("siii", $status, $processed, $failed, $operationId);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
// Update operation status. This is bookkeeping only and runs after the
|
||||
// ticket changes are committed, so a failure here (e.g. the status column
|
||||
// not yet widened by 001_widen_bulk_operations_status.sql on an instance
|
||||
// deployed ahead of its migrations) must not turn a completed operation
|
||||
// into an error response.
|
||||
try {
|
||||
$status = $failed > 0 ? 'completed_with_errors' : 'completed';
|
||||
$sql = "UPDATE bulk_operations SET status = ?, processed_tickets = ?, failed_tickets = ?,
|
||||
completed_at = NOW() WHERE operation_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("siii", $status, $processed, $failed, $operationId);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
} catch (Throwable $e) {
|
||||
error_log("Bulk operation $operationId completed but status bookkeeping failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
$result = ['processed' => $processed, 'failed' => $failed];
|
||||
if (!empty($errors)) {
|
||||
@@ -350,6 +445,37 @@ class BulkOperationsModel
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post the bulk status-change reason as a comment on one ticket.
|
||||
*
|
||||
* Runs inside the caller's transaction, so a rollback drops the comment along
|
||||
* with the status change.
|
||||
*/
|
||||
private function postBulkComment(string $ticketId, int $userId, string $text): void
|
||||
{
|
||||
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||
|
||||
if ($this->commentModel === null) {
|
||||
$this->commentModel = new CommentModel($this->conn);
|
||||
}
|
||||
if (!isset($this->userNames[$userId])) {
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT COALESCE(NULLIF(display_name, ''), username) AS name FROM users WHERE user_id = ? LIMIT 1"
|
||||
);
|
||||
$stmt->bind_param("i", $userId);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
$this->userNames[$userId] = $row['name'] ?? 'User';
|
||||
}
|
||||
|
||||
$this->commentModel->addComment($ticketId, [
|
||||
'user_name' => $this->userNames[$userId],
|
||||
'comment_text' => $text,
|
||||
'markdown_enabled' => 0,
|
||||
], $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a user ID exists.
|
||||
*/
|
||||
|
||||
@@ -172,6 +172,27 @@ class DependencyModel
|
||||
}
|
||||
$checkStmt->close();
|
||||
|
||||
// Also check the semantic inverse: "A blocks B" and "B blocked_by A"
|
||||
// describe the same relationship, so adding one from either ticket's
|
||||
// page must be rejected as a duplicate of the other. relates_to is
|
||||
// its own inverse (symmetric); duplicates has no defined inverse type.
|
||||
$inverseTypes = ['blocks' => 'blocked_by', 'blocked_by' => 'blocks', 'relates_to' => 'relates_to'];
|
||||
if (isset($inverseTypes[$type])) {
|
||||
$inverseType = $inverseTypes[$type];
|
||||
$checkInverseSql = "SELECT dependency_id FROM ticket_dependencies
|
||||
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
|
||||
$checkInverseStmt = $this->conn->prepare($checkInverseSql);
|
||||
$checkInverseStmt->bind_param("sss", $dependsOnId, $ticketId, $inverseType);
|
||||
$checkInverseStmt->execute();
|
||||
$inverseResult = $checkInverseStmt->get_result();
|
||||
|
||||
if ($inverseResult->num_rows > 0) {
|
||||
$checkInverseStmt->close();
|
||||
return ['success' => false, 'error' => 'This relationship already exists'];
|
||||
}
|
||||
$checkInverseStmt->close();
|
||||
}
|
||||
|
||||
// Check for circular dependency
|
||||
if ($this->wouldCreateCycle($ticketId, $dependsOnId, $type)) {
|
||||
return ['success' => false, 'error' => 'This would create a circular dependency'];
|
||||
|
||||
@@ -189,30 +189,6 @@ class RecurringTicketModel
|
||||
return $claimed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last run and calculate next run time
|
||||
*/
|
||||
public function updateAfterRun($recurringId)
|
||||
{
|
||||
$recurring = $this->getById($recurringId);
|
||||
if (!$recurring) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$nextRun = $this->calculateNextRunTime(
|
||||
$recurring['schedule_type'],
|
||||
$recurring['schedule_day'],
|
||||
$recurring['schedule_time']
|
||||
);
|
||||
|
||||
$sql = "UPDATE recurring_tickets SET last_run_at = NOW(), next_run_at = ? WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('si', $nextRun, $recurringId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next run time based on schedule
|
||||
*/
|
||||
@@ -255,9 +231,33 @@ class RecurringTicketModel
|
||||
*/
|
||||
public function toggleActive($recurringId)
|
||||
{
|
||||
$sql = "UPDATE recurring_tickets SET is_active = NOT is_active WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $recurringId);
|
||||
$recurring = $this->getById($recurringId);
|
||||
if (!$recurring) {
|
||||
return ['success' => false];
|
||||
}
|
||||
|
||||
$newActive = $recurring['is_active'] ? 0 : 1;
|
||||
|
||||
if ($newActive) {
|
||||
// Re-enabling: recompute next_run_at from now, as if the schedule
|
||||
// were freshly created. Otherwise a schedule paused while
|
||||
// next_run_at was still in the future, then re-enabled after that
|
||||
// date has passed, would fire immediately on the next cron tick
|
||||
// instead of waiting for its next natural occurrence.
|
||||
$nextRun = $this->calculateNextRunTime(
|
||||
$recurring['schedule_type'],
|
||||
$recurring['schedule_day'],
|
||||
$recurring['schedule_time']
|
||||
);
|
||||
$sql = "UPDATE recurring_tickets SET is_active = ?, next_run_at = ? WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('isi', $newActive, $nextRun, $recurringId);
|
||||
} else {
|
||||
$sql = "UPDATE recurring_tickets SET is_active = ? WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('ii', $newActive, $recurringId);
|
||||
}
|
||||
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
|
||||
+18
-7
@@ -98,19 +98,30 @@ class UserModel
|
||||
$user['groups'] = $groups;
|
||||
$user['is_admin'] = $isAdmin;
|
||||
} else {
|
||||
// Create new user
|
||||
// Create new user. Uses INSERT ... ON DUPLICATE KEY UPDATE (rather than
|
||||
// a plain INSERT) so two concurrent first-visit requests for the same
|
||||
// brand-new username can't race: the losing request updates the row the
|
||||
// winner just created instead of throwing an uncaught duplicate-key
|
||||
// exception (users.username has a UNIQUE KEY, and mysqli throws on
|
||||
// constraint violation under PHP 8.1+'s default report mode).
|
||||
$insertStmt = $this->conn->prepare(
|
||||
"INSERT INTO users (username, display_name, email, `groups`, is_admin, last_login) VALUES (?, ?, ?, ?, ?, NOW())"
|
||||
"INSERT INTO users (username, display_name, email, `groups`, is_admin, last_login)
|
||||
VALUES (?, ?, ?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
display_name = VALUES(display_name),
|
||||
email = VALUES(email),
|
||||
`groups` = VALUES(groups),
|
||||
is_admin = VALUES(is_admin),
|
||||
last_login = NOW()"
|
||||
);
|
||||
$insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin);
|
||||
$insertStmt->execute();
|
||||
|
||||
$userId = $this->conn->insert_id;
|
||||
$insertStmt->close();
|
||||
|
||||
// Get the newly created user
|
||||
$stmt = $this->conn->prepare("SELECT * FROM users WHERE user_id = ?");
|
||||
$stmt->bind_param("i", $userId);
|
||||
// Re-fetch by username — works whether this request won the insert or
|
||||
// lost the race and only updated the winner's row.
|
||||
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = ?");
|
||||
$stmt->bind_param("s", $username);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$user = $result->fetch_assoc();
|
||||
|
||||
@@ -10,9 +10,30 @@
|
||||
* Usage: php scripts/check_requirements.php
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a php.ini size value (e.g. "128M", "1G", "-1") into bytes.
|
||||
* Returns -1 for unlimited.
|
||||
*/
|
||||
function parseIniBytes(string $val): int
|
||||
{
|
||||
$val = trim($val);
|
||||
if ($val === '' || $val === '-1') {
|
||||
return -1;
|
||||
}
|
||||
$unit = strtolower(substr($val, -1));
|
||||
$num = (int)$val;
|
||||
return match ($unit) {
|
||||
'g' => $num * 1024 * 1024 * 1024,
|
||||
'm' => $num * 1024 * 1024,
|
||||
'k' => $num * 1024,
|
||||
default => $num,
|
||||
};
|
||||
}
|
||||
|
||||
$req = require __DIR__ . '/../config/requirements.php';
|
||||
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
|
||||
// PHP version
|
||||
$minPhp = $req['min_php_version'];
|
||||
@@ -27,6 +48,28 @@ foreach ($req['required_extensions'] as $ext) {
|
||||
}
|
||||
}
|
||||
|
||||
// memory_limit / max_execution_time sanity checks (warnings, not hard
|
||||
// failures — see config/requirements.php for why these matter).
|
||||
$memLimitIni = ini_get('memory_limit');
|
||||
$memLimitBytes = parseIniBytes($memLimitIni);
|
||||
$minMemBytes = $req['min_memory_limit_mb'] * 1024 * 1024;
|
||||
if ($memLimitBytes !== -1 && $memLimitBytes < $minMemBytes) {
|
||||
$warnings[] = sprintf(
|
||||
'memory_limit is %s, below the recommended minimum %dM',
|
||||
$memLimitIni,
|
||||
$req['min_memory_limit_mb']
|
||||
);
|
||||
}
|
||||
|
||||
$maxExecTime = (int)ini_get('max_execution_time');
|
||||
if ($maxExecTime !== 0 && $maxExecTime < $req['min_max_execution_time']) {
|
||||
$warnings[] = sprintf(
|
||||
'max_execution_time is %ds, below the recommended minimum %ds',
|
||||
$maxExecTime,
|
||||
$req['min_max_execution_time']
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
fwrite(STDERR, "Requirement check FAILED:\n");
|
||||
foreach ($errors as $err) {
|
||||
@@ -35,6 +78,10 @@ if (!empty($errors)) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
foreach ($warnings as $warn) {
|
||||
fwrite(STDERR, "Requirement check WARNING: " . $warn . "\n");
|
||||
}
|
||||
|
||||
printf(
|
||||
"Requirement check passed: PHP %s (>= %s); extensions: %s\n",
|
||||
PHP_VERSION,
|
||||
|
||||
@@ -344,12 +344,23 @@ include __DIR__ . '/layout_header.php';
|
||||
var existingTitle = (document.getElementById('title').value || '').trim();
|
||||
var existingDesc = (document.getElementById('description').value || '').trim();
|
||||
if (existingTitle || existingDesc) {
|
||||
if (!confirm('Applying this template will overwrite your current title and description. Continue?')) {
|
||||
document.getElementById('templateSelect').value = '';
|
||||
return;
|
||||
}
|
||||
showConfirmModal(
|
||||
'Overwrite content?',
|
||||
'Applying this template will overwrite your current title and description. Continue?',
|
||||
'warning',
|
||||
applyTemplate,
|
||||
function () { document.getElementById('templateSelect').value = ''; }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
applyTemplate();
|
||||
}
|
||||
|
||||
function applyTemplate() {
|
||||
var tplId = document.getElementById('templateSelect').value;
|
||||
if (!tplId) return;
|
||||
|
||||
lt.api.get('/api/get_template.php?template_id=' + encodeURIComponent(tplId))
|
||||
.then(function (data) {
|
||||
if (!data.success || !data.template) {
|
||||
|
||||
+82
-9
@@ -120,7 +120,6 @@ include __DIR__ . '/layout_header.php';
|
||||
?>
|
||||
|
||||
<div class="lt-stat-card stat-open" role="button" tabindex="0"
|
||||
data-filter-key="status" data-filter-val="Open,Pending,In Progress"
|
||||
title="Click to filter by active tickets" aria-label="Open tickets">
|
||||
<div class="lt-stat-icon">[ # ]</div>
|
||||
<div class="lt-stat-info">
|
||||
@@ -133,7 +132,6 @@ include __DIR__ . '/layout_header.php';
|
||||
</div>
|
||||
|
||||
<div class="lt-stat-card stat-critical" role="button" tabindex="0"
|
||||
data-filter-key="priority" data-filter-val="1"
|
||||
title="Click to filter critical (P1) tickets" aria-label="Critical P1 tickets">
|
||||
<div class="lt-stat-icon lt-text-danger">[ ! ]</div>
|
||||
<div class="lt-stat-info">
|
||||
@@ -146,7 +144,6 @@ include __DIR__ . '/layout_header.php';
|
||||
</div>
|
||||
|
||||
<div class="lt-stat-card stat-unassigned" role="button" tabindex="0"
|
||||
data-filter-key="assigned_to" data-filter-val="unassigned"
|
||||
title="Click to filter unassigned tickets" aria-label="Unassigned tickets">
|
||||
<div class="lt-stat-icon lt-text-amber">[ @ ]</div>
|
||||
<div class="lt-stat-info">
|
||||
@@ -171,7 +168,6 @@ include __DIR__ . '/layout_header.php';
|
||||
</div>
|
||||
|
||||
<div class="lt-stat-card stat-resolved" role="button" tabindex="0"
|
||||
data-filter-key="status" data-filter-val="Closed"
|
||||
title="Click to filter closed tickets" aria-label="Closed tickets today">
|
||||
<div class="lt-stat-icon lt-text-muted">[ OK ]</div>
|
||||
<div class="lt-stat-info">
|
||||
@@ -277,9 +273,66 @@ include __DIR__ . '/layout_header.php';
|
||||
array_values($stats['by_category'] ?? [])
|
||||
))) ?>;
|
||||
|
||||
// ── Click-to-filter ────────────────────────────────────────────────────────
|
||||
// Charts navigate to the same URL filters the stat cards use.
|
||||
//
|
||||
// The status the click filters on has to be explicit rather than left to the
|
||||
// default: with no `status` param the controller falls back to the viewer's
|
||||
// default_status_filters preference, which can be anything, so the resulting
|
||||
// list would not necessarily match what the chart counted. StatsModel builds
|
||||
// by_priority and by_category with `status != 'Closed'`, while by_status spans
|
||||
// every status — so only the priority and category charts pin the open set.
|
||||
function openStatuses() {
|
||||
var all = window.TICKET_STATUSES || ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||
return all.filter(function(s) { return s !== 'Closed'; }).join(',');
|
||||
}
|
||||
|
||||
function gotoFilter(params) {
|
||||
var qs = new URLSearchParams(window.location.search);
|
||||
Object.keys(params).forEach(function(k) {
|
||||
if (params[k] !== null && params[k] !== undefined && params[k] !== '') qs.set(k, params[k]);
|
||||
else qs.delete(k);
|
||||
});
|
||||
window.location.href = '/?' + qs.toString();
|
||||
}
|
||||
|
||||
// Each chart maps a clicked label to a filter. Returns null when the label
|
||||
// can't be mapped, so the click is simply ignored.
|
||||
var CHART_FILTERS = {
|
||||
chartPriority: function(label) {
|
||||
var m = /^P(\d+)$/.exec(label);
|
||||
return m ? { priority: m[1], status: openStatuses() } : null;
|
||||
},
|
||||
chartStatus: function(label) {
|
||||
return label ? { status: label } : null;
|
||||
},
|
||||
chartCategory: function(label) {
|
||||
return label ? { category: label, status: openStatuses() } : null;
|
||||
}
|
||||
};
|
||||
|
||||
function filterOnClick(canvasId) {
|
||||
return function(evt, elements, chart) {
|
||||
if (!elements || !elements.length) return;
|
||||
var label = chart.data.labels[elements[0].index];
|
||||
var mapper = CHART_FILTERS[canvasId];
|
||||
var params = mapper && mapper(label);
|
||||
if (params) gotoFilter(params);
|
||||
};
|
||||
}
|
||||
|
||||
// Pointer cursor over clickable segments so the affordance is visible.
|
||||
function filterOnHover(evt, elements) {
|
||||
if (evt && evt.native && evt.native.target) {
|
||||
evt.native.target.style.cursor = (elements && elements.length) ? 'pointer' : 'default';
|
||||
}
|
||||
}
|
||||
|
||||
function makeDonut(canvasId, data, colorMap) {
|
||||
var ctx = document.getElementById(canvasId);
|
||||
if (!ctx || !data.length) return;
|
||||
if (!ctx) return;
|
||||
if (!data.length) { showChartEmptyState(ctx); return; }
|
||||
ctx.title = 'Click a segment to filter the ticket list';
|
||||
return new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
@@ -295,21 +348,37 @@ include __DIR__ . '/layout_header.php';
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
onClick: filterOnClick(canvasId),
|
||||
onHover: filterOnHover,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { color: '#8fa3b1', font: { family: 'monospace', size: 10 }, padding: 8, boxWidth: 10 }
|
||||
},
|
||||
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.label + ': ' + ctx.parsed; } } }
|
||||
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.label + ': ' + ctx.parsed + ' — click to filter'; } } }
|
||||
},
|
||||
cutout: '68%'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showChartEmptyState(canvas) {
|
||||
canvas.style.display = 'none';
|
||||
var wrap = canvas.parentElement;
|
||||
if (wrap && !wrap.querySelector('.lt-chart-empty')) {
|
||||
var msg = document.createElement('div');
|
||||
msg.className = 'lt-chart-empty';
|
||||
msg.style.cssText = 'display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:0.75rem';
|
||||
msg.textContent = 'No data for current filters';
|
||||
wrap.appendChild(msg);
|
||||
}
|
||||
}
|
||||
|
||||
function makeBar(canvasId, data) {
|
||||
var ctx = document.getElementById(canvasId);
|
||||
if (!ctx || !data.length) return;
|
||||
if (!ctx) return;
|
||||
if (!data.length) { showChartEmptyState(ctx); return; }
|
||||
ctx.title = 'Click a bar to filter the ticket list';
|
||||
return new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
@@ -323,7 +392,12 @@ include __DIR__ . '/layout_header.php';
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
onClick: filterOnClick(canvasId),
|
||||
onHover: filterOnHover,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.parsed.x + ' — click to filter'; } } }
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { color: '#8fa3b1', font: { size: 10 } }, grid: { color: 'rgba(0,255,65,0.06)' } },
|
||||
y: { ticks: { color: '#8fa3b1', font: { family: 'monospace', size: 10 } }, grid: { display: false } }
|
||||
@@ -1162,7 +1236,6 @@ window.TICKET_STATUSES = <?= json_encode($GLOBALS['config']['TICKET_STATUSES'])
|
||||
if (window.lt) {
|
||||
lt.keys.initDefaults();
|
||||
lt.tableNav.init('tickets-table');
|
||||
lt.statsFilter.init();
|
||||
}
|
||||
|
||||
// Saved filter pills — load on page init
|
||||
|
||||
+4
-10
@@ -1006,9 +1006,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
shown.forEach(function (w) {
|
||||
var words = (w.display_name || '').trim().split(/\s+/).filter(Boolean);
|
||||
var initials = words.slice(0, 2).map(function (x) { return x[0].toUpperCase(); }).join('');
|
||||
var hash = 0;
|
||||
for (var i = 0; i < (w.display_name || '').length; i++) hash = ((hash << 5) - hash + (w.display_name || '').charCodeAt(i)) | 0;
|
||||
var color = avatarColors[Math.abs(hash) % 4];
|
||||
var color = avatarColors[crc32(w.display_name || '') % 4];
|
||||
html += '<div class="lt-avatar lt-avatar--xs ' + color + '" title="' + lt.escHtml(w.display_name) + '" aria-label="' + lt.escHtml(w.display_name) + '">' +
|
||||
'<img src="/api/user_avatar.php?user_id=' + w.user_id + '" alt="" class="lt-avatar-img">' +
|
||||
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
|
||||
@@ -1219,7 +1217,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
if (typeof parseMarkdown === 'function') {
|
||||
list.querySelectorAll('.comment-text[data-markdown]').forEach(function (el) {
|
||||
if (!el.dataset.rendered) {
|
||||
el.innerHTML = parseMarkdown(el.textContent);
|
||||
el.innerHTML = parseMarkdown(el.textContent.trim());
|
||||
el.dataset.rendered = '1';
|
||||
}
|
||||
});
|
||||
@@ -1252,13 +1250,9 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
var words = displayName.trim().split(/\s+/).filter(Boolean);
|
||||
var initials = words.slice(0, 2).map(function (w) { return w[0].toUpperCase(); }).join('');
|
||||
|
||||
// Avatar color (same modulo logic as PHP: crc32 mod 4)
|
||||
// Avatar color (real crc32, matching PHP's crc32 % 4 exactly)
|
||||
var avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
||||
var hash = 0;
|
||||
for (var i = 0; i < displayName.length; i++) {
|
||||
hash = ((hash << 5) - hash + displayName.charCodeAt(i)) | 0;
|
||||
}
|
||||
var avatarColor = avatarColors[Math.abs(hash) % 4];
|
||||
var avatarColor = avatarColors[crc32(displayName) % 4];
|
||||
|
||||
// Format date
|
||||
var dateStr = c.created_at || '';
|
||||
|
||||
+94
-12
@@ -38,8 +38,18 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
<option value="365">1 year</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="lt-form-group" style="flex:1;margin:0">
|
||||
<label class="lt-label" for="keyScope">Scope</label>
|
||||
<select id="keyScope" class="lt-select">
|
||||
<option value="read_write" selected>read_write</option>
|
||||
<option value="read">read</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="lt-btn lt-btn-primary" style="margin-bottom:0">GENERATE KEY</button>
|
||||
</form>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<!-- New key display (hidden by default) -->
|
||||
<div id="newKeyDisplay" class="lt-frame-inner lt-mt-sm is-hidden">
|
||||
@@ -63,6 +73,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Key Prefix</th>
|
||||
<th scope="col">Scope</th>
|
||||
<th scope="col">Created By</th>
|
||||
<th scope="col">Created</th>
|
||||
<th scope="col">Expires</th>
|
||||
@@ -72,14 +83,26 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($apiKeys)) : ?>
|
||||
<tr><td colspan="8" class="lt-empty">No API keys found. Generate one above.</td></tr>
|
||||
<?php else :
|
||||
foreach ($apiKeys as $key) : ?>
|
||||
<?php $expired = $key['expires_at'] && strtotime($key['expires_at']) < time(); ?>
|
||||
<?php
|
||||
$apiKeysList = $apiKeys['keys'] ?? [];
|
||||
if (empty($apiKeysList)) : ?>
|
||||
<tr><td colspan="9" class="lt-empty">No API keys found. Generate one above.</td></tr>
|
||||
<?php else :
|
||||
foreach ($apiKeysList as $key) : ?>
|
||||
<?php
|
||||
$expired = $key['expires_at'] && strtotime($key['expires_at']) < time();
|
||||
$scope = $key['scope'] ?? 'read_write';
|
||||
?>
|
||||
<tr id="key-row-<?= (int)$key['api_key_id'] ?>">
|
||||
<td data-label="Name"><strong><?= htmlspecialchars($key['key_name']) ?></strong></td>
|
||||
<td data-label="Prefix" class="lt-text-xs"><code><?= htmlspecialchars($key['key_prefix']) ?>…</code></td>
|
||||
<td data-label="Scope">
|
||||
<?php if ($scope === 'read') : ?>
|
||||
<span class="lt-status lt-status-closed"><?= htmlspecialchars($scope) ?></span>
|
||||
<?php else : ?>
|
||||
<span class="lt-status lt-status-open"><?= htmlspecialchars($scope) ?></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" 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' ?>">
|
||||
@@ -104,11 +127,30 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
<?php endif ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach;
|
||||
endif ?>
|
||||
<?php endforeach;
|
||||
endif ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<?php
|
||||
$akPage = (int)($apiKeys['page'] ?? 1);
|
||||
$akPerPage = max(1, (int)($apiKeys['perPage'] ?? 20));
|
||||
$akTotal = (int)($apiKeys['total'] ?? 0);
|
||||
$akPages = (int)ceil($akTotal / $akPerPage);
|
||||
?>
|
||||
<?php if ($akPages > 1) : ?>
|
||||
<div class="lt-pagination" role="navigation" aria-label="API keys pagination">
|
||||
<?php if ($akPage > 1) : ?>
|
||||
<a href="/admin/api-keys?page=<?= $akPage - 1 ?>" class="lt-btn lt-btn-sm" aria-label="Previous page">« Prev</a>
|
||||
<?php endif ?>
|
||||
<span class="lt-text-xs lt-text-muted">Page <?= $akPage ?> of <?= $akPages ?></span>
|
||||
<?php if ($akPage < $akPages) : ?>
|
||||
<a href="/admin/api-keys?page=<?= $akPage + 1 ?>" class="lt-btn lt-btn-sm" aria-label="Next page">Next »</a>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -127,17 +169,56 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
</div>
|
||||
<pre><code>Authorization: Bearer YOUR_API_KEY</code></pre>
|
||||
</div>
|
||||
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">
|
||||
Example — create a ticket via cURL:<br>
|
||||
<?php $apiBase = 'https://' . htmlspecialchars($GLOBALS['config']['APP_DOMAIN'] ?? 'your-instance', ENT_QUOTES); ?>
|
||||
<p class="lt-text-sm lt-text-muted" style="margin-top:0.75rem">
|
||||
<strong>Scopes:</strong> a <code>read</code> key may only use the <code>GET</code> endpoints;
|
||||
a <code>read_write</code> key may also create tickets, post comments, and change status.
|
||||
All endpoints are Bearer-authenticated and rate-limited. Comments and status changes made via
|
||||
the API are attributed to the key's name.
|
||||
</p>
|
||||
|
||||
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>Create a ticket</strong> (read_write):</p>
|
||||
<div class="lt-code-block">
|
||||
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
|
||||
<pre><code>curl -X POST https://your-instance/create_ticket_api.php \
|
||||
<pre><code>curl -X POST <?= $apiBase ?>/create_ticket_api.php \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title":"My ticket","category":"General","type":"Issue","priority":3}'</code></pre>
|
||||
</div>
|
||||
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">API keys provide programmatic access to create and manage tickets. Keep keys secure and rotate them regularly.</p>
|
||||
|
||||
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>List / triage the queue</strong> (read). Filters: <code>status</code>, <code>priority</code> (1-5), <code>host</code> (title match), <code>page</code>, <code>limit</code>:</p>
|
||||
<div class="lt-code-block">
|
||||
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
|
||||
<pre><code>curl "<?= $apiBase ?>/api/tickets_api.php?status=Open&priority=2&limit=25" \
|
||||
-H "Authorization: Bearer YOUR_API_KEY"</code></pre>
|
||||
</div>
|
||||
|
||||
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>Read one ticket + its comments</strong> (read):</p>
|
||||
<div class="lt-code-block">
|
||||
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
|
||||
<pre><code>curl "<?= $apiBase ?>/api/tickets_api.php?ticket_id=123456789" \
|
||||
-H "Authorization: Bearer YOUR_API_KEY"</code></pre>
|
||||
</div>
|
||||
|
||||
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>Post a comment</strong> (read_write). <code>markdown_enabled</code> is optional:</p>
|
||||
<div class="lt-code-block">
|
||||
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
|
||||
<pre><code>curl -X POST <?= $apiBase ?>/api/ticket_comment_api.php \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ticket_id":"123456789","comment_text":"Investigating.","markdown_enabled":true}'</code></pre>
|
||||
</div>
|
||||
|
||||
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>Change / close status</strong> (read_write, workflow-validated). <code>comment</code> is required for transitions that require one (e.g. closing) and is posted as the reason:</p>
|
||||
<div class="lt-code-block">
|
||||
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
|
||||
<pre><code>curl -X POST <?= $apiBase ?>/api/ticket_status_api.php \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ticket_id":"123456789","status":"Closed","comment":"Resolved: disk replaced."}'</code></pre>
|
||||
</div>
|
||||
|
||||
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem">Keep keys secure and rotate them regularly. Scope automation keys to <code>read</code> unless they need to write.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -160,8 +241,9 @@ document.getElementById('generateKeyForm').addEventListener('submit', function (
|
||||
e.preventDefault();
|
||||
var keyName = document.getElementById('keyName').value.trim();
|
||||
var expiresIn = document.getElementById('expiresIn').value;
|
||||
var keyScope = document.getElementById('keyScope').value;
|
||||
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 })
|
||||
lt.api.post('/api/generate_api_key.php', { key_name: keyName, expires_in_days: expiresIn || null, scope: keyScope })
|
||||
.then(function (data) {
|
||||
if (data.success) {
|
||||
document.getElementById('newKeyValue').value = data.api_key;
|
||||
|
||||
+30
-4
@@ -235,11 +235,12 @@
|
||||
}
|
||||
|
||||
function loadNotifications() {
|
||||
fetch('/api/notifications.php', { credentials: 'same-origin' })
|
||||
return fetch('/api/notifications.php', { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(renderNotifications)
|
||||
.then(function(data) { renderNotifications(data); return true; })
|
||||
.catch(function() {
|
||||
list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>';
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -261,9 +262,34 @@
|
||||
document.addEventListener('click', function(e) { if (_open && wrapEl && !wrapEl.contains(e.target)) closePanel(); });
|
||||
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && _open) closePanel(); });
|
||||
|
||||
// Initial badge count + poll every 60s
|
||||
// Poll every 60s while the tab is visible, backing off (up to 5 min) on
|
||||
// repeated failures, and resuming immediately when the tab regains focus.
|
||||
var POLL_INTERVAL = 60000;
|
||||
var MAX_POLL_INTERVAL = 300000;
|
||||
var _pollTimer = null;
|
||||
var _failCount = 0;
|
||||
|
||||
function scheduleNextPoll(delay) {
|
||||
clearTimeout(_pollTimer);
|
||||
_pollTimer = setTimeout(pollNotifications, delay);
|
||||
}
|
||||
|
||||
function pollNotifications() {
|
||||
if (document.hidden) return;
|
||||
loadNotifications().then(function(ok) {
|
||||
_failCount = ok ? 0 : _failCount + 1;
|
||||
var delay = ok ? POLL_INTERVAL : Math.min(POLL_INTERVAL * Math.pow(2, _failCount), MAX_POLL_INTERVAL);
|
||||
scheduleNextPoll(delay);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (!document.hidden) pollNotifications();
|
||||
});
|
||||
|
||||
// Initial badge count, then start the poll cycle
|
||||
loadNotifications();
|
||||
setInterval(loadNotifications, 60000);
|
||||
scheduleNextPoll(POLL_INTERVAL);
|
||||
})();
|
||||
<?php endif ?>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user