Compare commits

...
Author SHA1 Message Date
jaredandClaude Sonnet 5 d0a763079e Merge development into main: API key visibility, notification retry, watch audit logging (#70, #78, #93)
Lint / PHP (phpcs PSR-12) (push) Successful in 35s
Lint / JS (eslint) (push) Successful in 26s
Lint / PHP requirements (version + extensions) (push) Successful in 43s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m45s
Lint / Deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-12 01:20:57 -04:00
jaredandClaude Sonnet 5 35192aaadc Retry failed Matrix webhook notifications with backoff (#78)
Lint / PHP (phpcs PSR-12) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 21s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m8s
Lint / Deploy (push) Successful in 5s
NotificationHelper::fire() logged a failed webhook post via error_log()
only, with no retry and no persistent record — once a notification
failed, it was gone with no trace beyond the log line, even though the
underlying DB write (audit_log entry, status change, etc.) it was
reporting on had already committed.

Extracted the curl POST into attemptDelivery(), shared between fire()
(unchanged best-effort caller-facing behavior) and the new
cron/retry_failed_notifications.php. On failure, fire() now also queues
the payload to notification_retry_queue (migration 007) via
Database::getConnection() — most fire() call sites don't have a $conn
handy, and threading one through every caller would be a much larger,
more invasive change than reusing the existing connection singleton.

The cron script processes due rows with exponential backoff (2, 4, 8...
capped at 60 minutes) up to each row's max_attempts (default 6), then
leaves an exhausted row in place — not deleted — so it stays visible
for manual investigation instead of disappearing a second time.

Verified against real MariaDB and a local HTTP server standing in for
the Matrix webhook, toggled between failing and succeeding: confirmed
a real failure via fire() is correctly queued; the retry script
reschedules a still-failing row with the expected backoff delay;
flipping the fake webhook to succeed lets the same row's next retry
delete it; a row that exhausts all attempts is left in place and
correctly excluded from the next run's due-row query; and a success
via fire() queues nothing (no regression on the common case).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-12 01:18:13 -04:00
jaredandClaude Sonnet 5 6609320c83 Restrict API keys to public-visibility tickets by default (#70)
api/tickets_api.php (both the single-ticket read and the list/triage
path) bypassed ticket visibility entirely for any 'read'-scope key,
regardless of who it was issued to or what it was for — any key got
blanket read access to Confidential and Internal ticket titles,
descriptions, and comments, with no way to scope a key more narrowly.

Added see_all_visibility to api_keys (migration 006), defaulting to
false for both new and existing keys — the prior blanket-access
behavior is what's being restricted here, so unlike scope's own
un-migrated-database fallback (which defaults toward preserving old
behavior), a missing/null value here defaults to the new, restrictive
one. An admin can opt a specific key in via a new checkbox in the API
Key Management UI when it genuinely needs the full queue.

tickets_api.php now builds a synthetic "no special access" user and
runs it through TicketModel's existing per-user visibility plumbing
(getVisibilityFilter/canUserAccessTicket) instead of a separate SQL
path, so this stays in lockstep with however visibility rules evolve
for real users. That synthetic user_id is -1, not 0: testing surfaced
that canUserAccessTicket()'s confidential-ticket check does a PHP-level
(int) cast, and (int)null === 0, so an unassigned confidential ticket's
NULL assigned_to would otherwise false-positive-match a user_id of 0.

Verified against real MariaDB with public/confidential/internal test
tickets: a public-only-scoped key's list only returns the public
ticket, and canUserAccessTicket() correctly returns false for both the
confidential ticket (unassigned, then reassigned to a real user — both
cases) and the internal one; a see_all_visibility key sees all three,
unchanged from the prior behavior. Also verified createKey()/
validateKey()'s default-false and explicit-true paths round-trip
correctly through the real DB.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-12 01:17:53 -04:00
jaredandClaude Sonnet 5 e91f4547b6 Log watch/unwatch actions to the audit trail (#93)
api/watch_ticket.php performed the ticket_watchers INSERT IGNORE/DELETE
directly with no AuditLogModel call, unlike every other ticket-adjacent
mutation (comments, attachments, dependencies, status/field changes),
so watching/unwatching never showed up in a ticket's timeline.

Added AuditLogModel::log() calls to both the watch and unwatch paths,
gated on the DB statement's affected_rows so a no-op (already watching,
already not watching) doesn't produce a duplicate timeline entry. Added
'watch'/'unwatch' to AuditLogModel's VALID_ACTION_TYPES, and timeline
rendering in views/TicketView.php ("started watching this ticket" /
"stopped watching this ticket").

Verified against real MariaDB: watch -> unwatch -> watch again produces
exactly 2 timeline entries (not 4) since the two no-op repeats correctly
produced zero rows changed and were not logged; confirmed formatAction()/
getEventIcon() render both action types correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-12 01:17:13 -04:00
jaredandClaude Sonnet 5 74544ac5b4 Merge development into main: LDAP avatar lookups now use LDAPS (#95)
Lint / PHP (phpcs PSR-12) (push) Successful in 30s
Lint / JS (eslint) (push) Successful in 16s
Lint / PHP requirements (version + extensions) (push) Successful in 39s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m21s
Lint / Deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-12 00:59:21 -04:00
jaredandClaude Sonnet 5 863f84f37e Switch LDAP avatar lookups to LDAPS (#95)
Lint / PHP (phpcs PSR-12) (push) Successful in 18s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 28s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m56s
Lint / Deploy (push) Successful in 3s
api/user_avatar.php connected via ldap://$ldapHost:$ldapPort — never
ldaps://, and there was no ldap_start_tls() call anywhere in the
codebase. LDAP_BIND_PW was sent over the wire unencrypted on every
avatar fetch.

Switched to ldaps://, and changed LDAP_HOST/LDAP_PORT's defaults to
ldap.lotusguild.org:6360 (lldap's LDAPS listener) instead of the bare
IP on port 3890 (plaintext). PHP's ldap extension verifies the server
cert's hostname by default, so a bare IP won't validate against the
LDAPS cert (issued for *.lotusguild.org) — LDAP_HOST has to be a
hostname the cert covers. This is deliberately not configurable back to
plaintext ldap://.

Infra change (pve-infra, separate repo/commit): added a Pi-hole
split-horizon override so ldap.lotusguild.org resolves internally to
the real LDAP server's LAN IP — its existing public DNS record points
elsewhere (an unrelated host), and there was no internal-only DNS entry
for it before this.

Verified against the real lldap server (pct 147, LDAPS on 6360, a live
Let's Encrypt *.lotusguild.org cert): confirmed the Pi-hole override
resolves correctly from hosts using it as their resolver, then ran the
exact ldap_connect/ldap_bind sequence via `php -r` directly on the
production tinker_tickets host (10.10.10.45) with a deliberately wrong
bind password — got "Invalid credentials" (a real LDAP protocol
response), not a transport/TLS error, proving the full connect + TLS
handshake + hostname verification + bind path works end-to-end in the
actual deployment environment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-12 00:56:00 -04:00
jaredandClaude Sonnet 5 b73a4c792c Merge development into main: dashboard status chart excludes Closed (#110)
Lint / PHP (phpcs PSR-12) (push) Successful in 54s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 24s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m2s
Lint / Deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 22:26:37 -04:00
jaredandClaude Sonnet 5 78ee5fdf48 Exclude Closed tickets from the dashboard status breakdown chart (#110)
Lint / PHP (phpcs PSR-12) (push) Successful in 31s
Lint / JS (eslint) (push) Successful in 13s
Lint / PHP requirements (version + extensions) (push) Successful in 31s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m4s
Lint / Deploy (push) Successful in 6s
StatsModel::getAllStats()'s by_status breakdown grouped every status
including Closed, unlike by_priority and by_category which already
filter status != 'Closed'. Closed tickets accumulate indefinitely, so
over time the status donut chart's Closed slice comes to dominate it,
squeezing Open/Pending/In Progress down to barely-visible slivers —
exactly the breakdown the chart exists to show at a glance.

Filtered by_status the same way the other two breakdowns already are.
The separate open_tickets/closed_tickets KPI counts are unaffected (a
different query); Closed just no longer appears as a chart segment.

Note: this issue was labeled 'invalid' with no explanation in the body
or comments — checked and it's Gitea's generic stock label (description
"Something is wrong"), not a documented decision that the request itself
was wrong. The described problem is real and reproducible in the code,
so implementing it as filed.

Verified against real MariaDB: seeded 3 open-ish tickets (Open, Pending,
In Progress) and 3 Closed. by_status now returns only the 3 active
statuses; open_tickets/closed_tickets KPI counts are unchanged at 3/3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 22:23:33 -04:00
jaredandClaude Sonnet 5 786674abf3 Merge development into main: user-activity fix + attachment thumbnails (#49, #98)
Lint / PHP (phpcs PSR-12) (push) Successful in 18s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m48s
Lint / Deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 22:19:57 -04:00
jaredandClaude Sonnet 5 dcf9b0cfa1 Generate real resized thumbnails for image attachments (#98)
Lint / PHP (phpcs PSR-12) (push) Successful in 17s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m8s
Lint / Deploy (push) Successful in 2s
The attachment grid's <img> thumbnail pointed at the same
download_attachment.php URL as the full-size original, so previewing a
multi-MB photo attachment cost a full multi-MB download just to render a
small grid preview. loading="lazy" only deferred off-screen images; it
never reduced per-image transfer size.

Generate a resized JPEG thumbnail (longest side capped at 300px) via GD
at upload time, from the same metadata-stripped image stripImageMetadata()
already produces, reusing its decompression-bomb guard (~40MP decode cap).
Store the thumbnail's filename in a new nullable ticket_attachments.
thumbnail_filename column (migration 005); NULL means no thumbnail exists
(non-image, GD unavailable, or an attachment predating this change) and
callers fall back to the full-size original.

download_attachment.php serves the thumbnail when requested via
?thumb=1 and one exists, falling back to the original otherwise. The
attachments grid now requests thumb=1 for its <img> preview; the
lightbox link is unchanged and still opens the full-size original.
delete_attachment.php removes the thumbnail file alongside the original,
and cleanup_orphan_uploads.php's orphan lookup now also matches
thumbnail_filename so generated thumbnails aren't swept up as orphans.

Verified against real MariaDB + GD: a 1600x1200 test JPEG produced a
300x225 thumbnail at ~1.8KB vs. the 52KB original (~29x smaller);
confirmed the serving logic picks the thumbnail for image attachments
with one, falls back to the original for a non-image attachment even
when thumb=1 is requested, and that the updated orphan-cleanup lookup
matches both the original and thumbnail filename (and correctly finds
neither for an unrelated filename).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 22:17:35 -04:00
jaredandClaude Sonnet 5 80169de16d Fix User Activity report's Tickets Assigned column to use assignment date (#49)
The "Tickets Assigned" column filtered by tickets.created_at, so a ticket
created outside the selected date range but assigned to a user within it
never counted, while one created in-range but assigned/reassigned later
counted as if the assignment happened in-range — filtered by the wrong
date field for what the column claims to measure.

tickets has no assigned_at column, so derive the count from audit_log's
'assign' events (already logged by both the single-ticket and bulk-assign
paths) instead, filtered by the event's own created_at. COUNT(DISTINCT
entity_id) so a ticket reassigned more than once to the same user within
the range still counts once.

Verified against real MariaDB: seeded one ticket created outside the test
range but assigned inside it, and one created inside the range but
assigned outside it. The old query counted the wrong one; the new query
correctly flips to count the one actually assigned within the range.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 22:17:25 -04:00
26 changed files with 518 additions and 64 deletions
+7 -3
View File
@@ -74,10 +74,14 @@ TRUSTED_PROXIES=
; Timezone (default: America/New_York) ; Timezone (default: America/New_York)
TIMEZONE=America/New_York TIMEZONE=America/New_York
; LDAP / lldap (for user avatar lookups) ; LDAP / lldap (for user avatar lookups). Connects over LDAPS — 6360 is
; lldap's default LDAPS port, NOT its plaintext port (3890), since
; LDAP_BIND_PW below would otherwise go over the wire unencrypted.
; LDAP_HOST must be a hostname matching the LDAPS cert (TLS hostname
; verification is not skipped), not a bare IP.
LDAP_ENABLED=true LDAP_ENABLED=true
LDAP_HOST=10.10.10.39 LDAP_HOST=ldap.lotusguild.org
LDAP_PORT=3890 LDAP_PORT=6360
LDAP_BIND_DN="uid=tinker-tickets,ou=people,dc=example,dc=com" LDAP_BIND_DN="uid=tinker-tickets,ou=people,dc=example,dc=com"
LDAP_BIND_PW= LDAP_BIND_PW=
LDAP_BASE_DN="dc=example,dc=com" LDAP_BASE_DN="dc=example,dc=com"
+6
View File
@@ -97,12 +97,15 @@ The following features are intentionally **not planned** for this system:
- **Admin UI**: Generate and manage API keys at `/admin/api-keys` (paginated) - **Admin UI**: Generate and manage API keys at `/admin/api-keys` (paginated)
- **Bearer Token Auth**: Use API keys with `Authorization: Bearer YOUR_KEY` header - **Bearer Token Auth**: Use API keys with `Authorization: Bearer YOUR_KEY` header
- **Key Scopes**: `read` (GET only) or `read_write` (create/comment/close). A `read` key cannot mutate anything, including creating tickets. Existing keys default to `read_write`. - **Key Scopes**: `read` (GET only) or `read_write` (create/comment/close). A `read` key cannot mutate anything, including creating tickets. Existing keys default to `read_write`.
- **Visibility Scope**: keys default to **public-visibility tickets only** — Confidential and Internal tickets are excluded from `/api/tickets_api.php`, same as they'd be for a regular user with no special access. Check **See all visibility** when generating a key only if that specific integration genuinely needs the full queue; this is a deliberate opt-in, not something a key gets by having `read_write` scope or any other setting.
- **Expiration**: Optional expiration dates for keys - **Expiration**: Optional expiration dates for keys
- **Revocation**: Revoke compromised keys instantly - **Revocation**: Revoke compromised keys instantly
### Bearer API (automation / triage) ### Bearer API (automation / triage)
All Bearer-authenticated, rate-limited, and (like `create_ticket_api.php`) exempt from Authelia at the reverse proxy — the API key is the only credential. Comments/closes made via the API are attributed to the **key's name** (linked to the key's owner). All Bearer-authenticated, rate-limited, and (like `create_ticket_api.php`) exempt from Authelia at the reverse proxy — the API key is the only credential. Comments/closes made via the API are attributed to the **key's name** (linked to the key's owner).
`/api/tickets_api.php` filters by ticket visibility according to the key's **Visibility Scope** setting above (public-only by default); every other Bearer endpoint below operates on a single ticket_id supplied by the caller and does not filter a list, so this setting doesn't apply to them.
| Endpoint | Method | Scope | Purpose | | Endpoint | Method | Scope | Purpose |
|----------|--------|-------|---------| |----------|--------|-------|---------|
| `/create_ticket_api.php` | POST | read_write | Create a ticket (hwmonDaemon, external tools) | | `/create_ticket_api.php` | POST | read_write | Create a ticket (hwmonDaemon, external tools) |
@@ -522,6 +525,9 @@ Add to crontab for recurring tickets and maintenance cleanup:
# Delete orphaned upload files with no attachment row, past a 24h grace period (daily). # Delete orphaned upload files with no attachment row, past a 24h grace period (daily).
# Add --dry-run to preview without deleting. # Add --dry-run to preview without deleting.
0 4 * * * php /path/to/tinkertickets/scripts/cleanup_orphan_uploads.php 0 4 * * * php /path/to/tinkertickets/scripts/cleanup_orphan_uploads.php
# Retry failed Matrix webhook notifications (exponential backoff, every 5 minutes)
*/5 * * * * php /path/to/tinkertickets/cron/retry_failed_notifications.php
``` ```
### 3. File Uploads ### 3. File Uploads
+9
View File
@@ -94,6 +94,15 @@ try {
} }
} }
// Delete the generated preview thumbnail alongside the original, if one exists.
if (!empty($attachment['thumbnail_filename'])) {
$thumbPath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['thumbnail_filename'];
$realThumbPath = realpath($thumbPath);
if ($realThumbPath !== false && strncmp($realThumbPath, $uploadDir . DIRECTORY_SEPARATOR, strlen($uploadDir) + 1) === 0) {
@unlink($realThumbPath);
}
}
// Delete from database // Delete from database
if (!$attachmentModel->deleteAttachment($attachmentId)) { if (!$attachmentModel->deleteAttachment($attachmentId)) {
ResponseHelper::serverError('Failed to delete attachment record'); ResponseHelper::serverError('Failed to delete attachment record');
+15 -3
View File
@@ -69,9 +69,21 @@ try {
$conn->close(); $conn->close();
// Serve the resized preview thumbnail instead of the full-size original
// when requested and one was actually generated at upload time; falls
// through to the full original otherwise (older attachments predating
// thumbnail generation, non-images, or a GD failure at upload time).
$wantsThumb = isset($_GET['thumb']) && $_GET['thumb'] === '1';
$servedFilename = $attachment['filename'];
$servedMimeType = $attachment['mime_type'];
if ($wantsThumb && !empty($attachment['thumbnail_filename'])) {
$servedFilename = $attachment['thumbnail_filename'];
$servedMimeType = 'image/jpeg';
}
// Build file path // Build file path
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads'; $uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename']; $filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $servedFilename;
// Security: Verify the resolved path is within the uploads directory (prevent path traversal) // Security: Verify the resolved path is within the uploads directory (prevent path traversal)
$realUploadDir = realpath($uploadDir); $realUploadDir = realpath($uploadDir);
@@ -100,7 +112,7 @@ try {
$inlineTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain']; $inlineTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain'];
// Set headers // Set headers
$disposition = ($inline && in_array($attachment['mime_type'], $inlineTypes)) ? 'inline' : 'attachment'; $disposition = ($inline && in_array($servedMimeType, $inlineTypes)) ? 'inline' : 'attachment';
// Sanitize filename for Content-Disposition // Sanitize filename for Content-Disposition
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']); $safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
@@ -138,7 +150,7 @@ try {
$rangeLength = $rangeEnd - $rangeStart + 1; $rangeLength = $rangeEnd - $rangeStart + 1;
header('Accept-Ranges: bytes'); header('Accept-Ranges: bytes');
header('Content-Type: ' . $attachment['mime_type']); header('Content-Type: ' . $servedMimeType);
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"'); header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
header('Cache-Control: private, max-age=3600'); header('Cache-Control: private, max-age=3600');
header('X-Content-Type-Options: nosniff'); header('X-Content-Type-Options: nosniff');
+4 -2
View File
@@ -67,6 +67,7 @@ try {
$keyName = trim($input['key_name'] ?? ''); $keyName = trim($input['key_name'] ?? '');
$expiresInDays = $input['expires_in_days'] ?? null; $expiresInDays = $input['expires_in_days'] ?? null;
$scope = $input['scope'] ?? 'read_write'; $scope = $input['scope'] ?? 'read_write';
$seeAllVisibility = !empty($input['see_all_visibility']);
if (empty($keyName)) { if (empty($keyName)) {
http_response_code(400); http_response_code(400);
@@ -100,7 +101,7 @@ try {
// Generate API key // Generate API key
$apiKeyModel = new ApiKeyModel($conn); $apiKeyModel = new ApiKeyModel($conn);
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays, $scope); $result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays, $scope, $seeAllVisibility);
if (!$result['success']) { if (!$result['success']) {
throw new Exception($result['error'] ?? "Failed to generate API key"); throw new Exception($result['error'] ?? "Failed to generate API key");
@@ -113,7 +114,7 @@ try {
'create', 'create',
'api_key', 'api_key',
$result['key_id'], $result['key_id'],
['key_name' => $keyName, 'expires_in_days' => $expiresInDays, 'scope' => $scope] ['key_name' => $keyName, 'expires_in_days' => $expiresInDays, 'scope' => $scope, 'see_all_visibility' => $seeAllVisibility]
); );
// Clear output buffer // Clear output buffer
@@ -127,6 +128,7 @@ try {
'key_prefix' => $result['key_prefix'], 'key_prefix' => $result['key_prefix'],
'key_id' => $result['key_id'], 'key_id' => $result['key_id'],
'scope' => $result['scope'], 'scope' => $result['scope'],
'see_all_visibility' => $result['see_all_visibility'],
'expires_at' => $result['expires_at'] 'expires_at' => $result['expires_at']
]); ]);
} catch (Exception $e) { } catch (Exception $e) {
+30 -5
View File
@@ -4,8 +4,10 @@
* tickets_api.php Bearer-key read endpoint (list/triage + read-one). * tickets_api.php Bearer-key read endpoint (list/triage + read-one).
* *
* GET only. Requires 'read' scope (a 'read_write' key also satisfies it). * GET only. Requires 'read' scope (a 'read_write' key also satisfies it).
* Acts as a trusted automation/server credential: reads return the full queue * By default, a key only sees public-visibility tickets Confidential and
* (no per-user visibility filtering). * Internal tickets are excluded, the same as they would be for a regular user
* with no special access. An admin can mark a specific key see_all_visibility
* (API Key Management) when it genuinely needs the full queue.
* *
* GET ?ticket_id=NNN -> {success, ticket, comments} * GET ?ticket_id=NNN -> {success, ticket, comments}
* GET ?status=&priority=&host= -> {success, tickets, page, total, pages} * GET ?status=&priority=&host= -> {success, tickets, page, total, pages}
@@ -48,6 +50,20 @@ try {
// Reads only need the 'read' scope. // Reads only need the 'read' scope.
$apiKeyAuth->requireScope('read'); $apiKeyAuth->requireScope('read');
// Keys are public-ticket-only by default (#70) — a key must be explicitly
// marked see_all_visibility to bypass Confidential/Internal restrictions.
// Reuse TicketModel's existing per-user visibility plumbing with a synthetic
// "no special access" user rather than a separate SQL path, so this stays in
// lockstep with however visibility rules evolve for real users. user_id is
// -1, not 0: canUserAccessTicket() does a PHP-level (int) cast for the
// confidential-ticket check, and (int)null === 0, so an unassigned
// confidential ticket's assigned_to would otherwise false-positive-match a
// synthetic user_id of 0. No real user_id is ever <= 0, so -1 can't collide.
$keyContext = $apiKeyAuth->getKeyContext();
$visibilityUser = !empty($keyContext['see_all_visibility'])
? null
: ['user_id' => -1, 'is_admin' => false, 'groups' => ''];
if ($_SERVER['REQUEST_METHOD'] !== 'GET') { if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405); http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed. Use GET.']); echo json_encode(['success' => false, 'error' => 'Method not allowed. Use GET.']);
@@ -67,6 +83,15 @@ if (isset($_GET['ticket_id']) && trim((string)$_GET['ticket_id']) !== '') {
exit; exit;
} }
// A public-only key gets a plain 404 for a non-public ticket — same as a
// regular user hitting a ticket they can't see — rather than a 403 that
// would confirm the ticket exists.
if ($visibilityUser !== null && !$ticketModel->canUserAccessTicket($ticket, $visibilityUser)) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
// Flat list of comments (newest first) — same fetch the ticket view uses. // Flat list of comments (newest first) — same fetch the ticket view uses.
$commentModel = new CommentModel($conn); $commentModel = new CommentModel($conn);
$comments = $commentModel->getCommentsByTicketId($ticketId, false); $comments = $commentModel->getCommentsByTicketId($ticketId, false);
@@ -114,8 +139,8 @@ if (isset($_GET['host']) && trim((string)$_GET['host']) !== '') {
$search = trim((string)$_GET['host']); $search = trim((string)$_GET['host']);
} }
// user = null => getAllTickets skips visibility filtering and returns the full // $visibilityUser is null (skip filtering, full queue) only for a key marked
// queue (this is a trusted server credential, not an end user). // see_all_visibility; otherwise it restricts to public tickets (see above).
$result = $ticketModel->getAllTickets( $result = $ticketModel->getAllTickets(
$page, $page,
$limit, $limit,
@@ -126,7 +151,7 @@ $result = $ticketModel->getAllTickets(
null, null,
$search, $search,
$filters, $filters,
null $visibilityUser
); );
echo json_encode([ echo json_encode([
+85 -4
View File
@@ -96,6 +96,72 @@ function stripImageMetadata(string $path, string $mimeType): void
} }
} }
/**
* Generate a resized preview thumbnail for an uploaded image, saved as a JPEG
* alongside the original regardless of source format (a thumbnail is a small
* lossy preview, not an archival copy). Longest side capped at
* THUMBNAIL_MAX_DIMENSION; images already at or below that size are still
* re-encoded (cheap) rather than skipped, so the thumbnail is guaranteed to
* be a JPEG the grid can always request the same way.
*
* Best-effort like stripImageMetadata(): returns null on any failure
* (corrupt image, unsupported format, GD unavailable) rather than blocking
* the upload, and the caller falls back to serving the full-size original.
*
* @return string|null Basename of the generated thumbnail file, or null
*/
function generateThumbnail(string $path, string $mimeType, string $destDir): ?string
{
if (!extension_loaded('gd')) {
return null;
}
// Same decompression-bomb guard as stripImageMetadata().
$dims = @getimagesize($path);
if ($dims === false) {
return null;
}
[$width, $height] = $dims;
if ($width * $height > 40_000_000) { // ~40 MP cap
return null;
}
$loaders = [
'image/jpeg' => 'imagecreatefromjpeg',
'image/png' => 'imagecreatefrompng',
'image/gif' => 'imagecreatefromgif',
'image/webp' => 'imagecreatefromwebp',
];
$loader = $loaders[$mimeType] ?? null;
if ($loader === null || !function_exists($loader)) {
return null;
}
$source = @$loader($path);
if ($source === false) {
return null;
}
$maxDimension = 300;
$scale = min(1.0, $maxDimension / max($width, $height));
$thumbWidth = max(1, (int)round($width * $scale));
$thumbHeight = max(1, (int)round($height * $scale));
$thumb = imagecreatetruecolor($thumbWidth, $thumbHeight);
// Flatten transparency onto white — thumbnails are always opaque JPEGs.
$white = imagecolorallocate($thumb, 255, 255, 255);
imagefill($thumb, 0, 0, $white);
imagecopyresampled($thumb, $source, 0, 0, 0, 0, $thumbWidth, $thumbHeight, $width, $height);
imagedestroy($source);
$thumbFilename = pathinfo($path, PATHINFO_FILENAME) . '_thumb.jpg';
$thumbPath = rtrim($destDir, '/') . '/' . $thumbFilename;
$saved = imagejpeg($thumb, $thumbPath, 80);
imagedestroy($thumb);
return $saved ? $thumbFilename : null;
}
// Check authentication // Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
ResponseHelper::unauthorized(); ResponseHelper::unauthorized();
@@ -133,6 +199,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
foreach ($attachments as &$att) { foreach ($attachments as &$att) {
$att['file_size_formatted'] = AttachmentModel::formatFileSize($att['file_size']); $att['file_size_formatted'] = AttachmentModel::formatFileSize($att['file_size']);
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']); $att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
$att['has_thumbnail'] = !empty($att['thumbnail_filename']);
unset($att['thumbnail_filename']); // internal storage detail, not needed by the client
} }
ResponseHelper::success([ ResponseHelper::success([
@@ -278,9 +346,14 @@ if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
ResponseHelper::serverError('Failed to move uploaded file'); ResponseHelper::serverError('Failed to move uploaded file');
} }
// Strip EXIF/GPS metadata from image uploads before it's ever served back // Strip EXIF/GPS metadata from image uploads before it's ever served back,
// then generate a resized preview thumbnail from the (now metadata-stripped)
// original so the grid never has to transfer the full-size file just to
// render a small preview.
$thumbnailFilename = null;
if (str_starts_with($mimeType, 'image/')) { if (str_starts_with($mimeType, 'image/')) {
stripImageMetadata($targetPath, $mimeType); stripImageMetadata($targetPath, $mimeType);
$thumbnailFilename = generateThumbnail($targetPath, $mimeType, $ticketDir);
} }
// Sanitize original filename // Sanitize original filename
@@ -298,12 +371,16 @@ try {
$originalFilename, $originalFilename,
$file['size'], $file['size'],
$mimeType, $mimeType,
$_SESSION['user']['user_id'] $_SESSION['user']['user_id'],
$thumbnailFilename
); );
if (!$attachmentId) { if (!$attachmentId) {
// Clean up file if database insert fails // Clean up file (and any thumbnail) if database insert fails
unlink($targetPath); unlink($targetPath);
if ($thumbnailFilename !== null) {
@unlink($ticketDir . '/' . $thumbnailFilename);
}
ResponseHelper::serverError('Failed to save attachment record'); ResponseHelper::serverError('Failed to save attachment record');
} }
@@ -330,13 +407,17 @@ try {
'file_size_formatted' => AttachmentModel::formatFileSize($file['size']), 'file_size_formatted' => AttachmentModel::formatFileSize($file['size']),
'mime_type' => $mimeType, 'mime_type' => $mimeType,
'icon' => AttachmentModel::getFileIcon($mimeType), 'icon' => AttachmentModel::getFileIcon($mimeType),
'has_thumbnail' => $thumbnailFilename !== null,
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'], 'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
'uploaded_at' => date('Y-m-d H:i:s') 'uploaded_at' => date('Y-m-d H:i:s')
], 'File uploaded successfully'); ], 'File uploaded successfully');
} catch (Exception $e) { } catch (Exception $e) {
// Clean up file on error // Clean up file (and any thumbnail) on error
if (file_exists($targetPath)) { if (file_exists($targetPath)) {
unlink($targetPath); unlink($targetPath);
} }
if (isset($thumbnailFilename) && $thumbnailFilename !== null) {
@unlink($ticketDir . '/' . $thumbnailFilename);
}
ResponseHelper::serverError('Failed to process attachment'); ResponseHelper::serverError('Failed to process attachment');
} }
+4 -1
View File
@@ -113,7 +113,10 @@ $avatarData = null;
$ldapQueryOk = false; // true only if the LDAP lookup completed without error $ldapQueryOk = false; // true only if the LDAP lookup completed without error
try { try {
$ldap = @ldap_connect("ldap://$ldapHost:$ldapPort"); // LDAPS, not plain ldap:// — LDAP_BIND_PW is sent during ldap_bind() below,
// and lldap's plaintext port (3890) would put it on the wire unencrypted.
// lldap's LDAPS listener defaults to port 6360 (see config.php).
$ldap = @ldap_connect("ldaps://$ldapHost:$ldapPort");
if (!$ldap) { if (!$ldap) {
throw new RuntimeException("ldap_connect failed"); throw new RuntimeException("ldap_connect failed");
} }
+10
View File
@@ -9,6 +9,7 @@
require_once __DIR__ . '/bootstrap.php'; require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
$data = json_decode(file_get_contents('php://input'), true) ?? []; $data = json_decode(file_get_contents('php://input'), true) ?? [];
@@ -43,6 +44,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
); );
$stmt->bind_param("si", $ticketId, $userId); $stmt->bind_param("si", $ticketId, $userId);
$stmt->execute(); $stmt->execute();
$rowsChanged = $stmt->affected_rows;
$stmt->close(); $stmt->close();
} else { } else {
$stmt = $conn->prepare( $stmt = $conn->prepare(
@@ -50,9 +52,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
); );
$stmt->bind_param("si", $ticketId, $userId); $stmt->bind_param("si", $ticketId, $userId);
$stmt->execute(); $stmt->execute();
$rowsChanged = $stmt->affected_rows;
$stmt->close(); $stmt->close();
} }
// Only log an actual state change — INSERT IGNORE/DELETE are no-ops when
// the user was already watching/not watching, and that shouldn't show up
// in the ticket's timeline as a new event.
if ($rowsChanged > 0) {
(new AuditLogModel($conn))->log($userId, $action, 'ticket', $ticketId);
}
// Return updated state // Return updated state
$countStmt = $conn->prepare( $countStmt = $conn->prepare(
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?" "SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
+7 -2
View File
@@ -1238,10 +1238,15 @@ function renderAttachments(attachments, append, hasMore) {
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 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 = /^image\//i.test(att.mime_type || ''); const isImage = /^image\//i.test(att.mime_type || '');
const imgUrl = `/api/download_attachment.php?id=${att.attachment_id}&inline=1`; const imgUrl = `/api/download_attachment.php?id=${att.attachment_id}&inline=1`;
// Grid preview requests the resized thumbnail (server falls back to the
// full-size original for attachments with none, e.g. uploaded before
// thumbnail generation existed); the lightbox link stays on the
// full-size original since that's what it displays when opened.
const thumbUrl = `${imgUrl}&thumb=1`;
const iconHtml = isImage const iconHtml = isImage
? `<a href="${imgUrl}" class="lt-lightbox-trigger" data-lightbox="ticket-attachments" title="${lt.escHtml(att.original_filename)}"> ? `<a href="${imgUrl}" class="lt-lightbox-trigger" data-lightbox="ticket-attachments" title="${lt.escHtml(att.original_filename)}">
<img src="${imgUrl}" alt="${lt.escHtml(att.original_filename)}" class="attachment-thumb" loading="lazy"> <img src="${thumbUrl}" alt="${lt.escHtml(att.original_filename)}" class="attachment-thumb" loading="lazy">
</a>` </a>`
: `<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>`; : `<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>`;
+13 -3
View File
@@ -154,9 +154,19 @@ $GLOBALS['config'] = [
'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York', 'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York',
'TIMEZONE_OFFSET' => null, // Will be calculated below 'TIMEZONE_OFFSET' => null, // Will be calculated below
// LDAP / lldap settings (for user avatar lookups) // LDAP / lldap settings (for user avatar lookups). Connects over LDAPS
'LDAP_HOST' => $envVars['LDAP_HOST'] ?? '10.10.10.39', // (see api/user_avatar.php) — lldap's default LDAPS port is 6360, not
'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 3890), // its plaintext port 3890. The bind password must never go over the
// wire unencrypted, so this is not configurable back to a plaintext
// ldap:// connection.
//
// LDAP_HOST must be a hostname matching the LDAPS cert's *.lotusguild.org
// CN/SAN, not a bare IP — PHP's ldap extension verifies the cert's
// hostname by default and a mismatch fails the connection. Pi-hole has a
// split-horizon override so ldap.lotusguild.org resolves internally to
// the real LDAP server IP (its public DNS record points elsewhere).
'LDAP_HOST' => $envVars['LDAP_HOST'] ?? 'ldap.lotusguild.org',
'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 6360),
'LDAP_BIND_DN' => $envVars['LDAP_BIND_DN'] ?? 'uid=tinker-tickets,ou=people,dc=example,dc=com', 'LDAP_BIND_DN' => $envVars['LDAP_BIND_DN'] ?? 'uid=tinker-tickets,ou=people,dc=example,dc=com',
'LDAP_BIND_PW' => $envVars['LDAP_BIND_PW'] ?? '', 'LDAP_BIND_PW' => $envVars['LDAP_BIND_PW'] ?? '',
'LDAP_BASE_DN' => $envVars['LDAP_BASE_DN'] ?? 'dc=example,dc=com', 'LDAP_BASE_DN' => $envVars['LDAP_BASE_DN'] ?? 'dc=example,dc=com',
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env php
<?php
/**
* Failed Matrix Notification Retry Cron Job
*
* NotificationHelper::fire() queues a failed webhook post to
* notification_retry_queue instead of just logging and losing it. This
* script retries due rows with exponential backoff, up to each row's
* max_attempts, then leaves an exhausted row in place (not deleted) so it
* remains visible for manual investigation.
*
* Run this via cron every 5-10 minutes (see README.md's Cron Jobs section
* for the exact crontab line the "every 5 minutes" syntax isn't repeated
* here since it would terminate this comment block early).
*/
// Prevent web access
if (php_sapi_name() !== 'cli') {
http_response_code(403);
exit('CLI access only');
}
chdir(dirname(__DIR__));
require_once 'config/config.php';
require_once 'helpers/Database.php';
require_once 'helpers/NotificationHelper.php';
function logMessage($message)
{
echo '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n";
}
/** Exponential backoff in minutes: 2, 4, 8, 16, 32, capped at 60. */
function nextAttemptDelayMinutes(int $attemptNumber): int
{
return min(60, 2 ** $attemptNumber);
}
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
if (empty($webhookUrl)) {
logMessage('MATRIX_WEBHOOK_URL not configured — nothing to retry, exiting.');
exit(0);
}
try {
$conn = Database::getConnection();
} catch (Exception $e) {
logMessage('FATAL ERROR: could not connect to database: ' . $e->getMessage());
exit(1);
}
// Process a bounded batch per run so one cron tick can't run indefinitely if
// the queue has backed up.
$batchLimit = 50;
$stmt = $conn->prepare(
"SELECT retry_id, payload, attempts, max_attempts
FROM notification_retry_queue
WHERE next_attempt_at <= NOW() AND attempts < max_attempts
ORDER BY retry_id ASC
LIMIT ?"
);
$stmt->bind_param('i', $batchLimit);
$stmt->execute();
$dueRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
if (empty($dueRows)) {
logMessage('No notifications due for retry.');
exit(0);
}
$succeeded = 0;
$failed = 0;
$exhausted = 0;
foreach ($dueRows as $row) {
$payload = json_decode($row['payload'], true);
if (!is_array($payload)) {
// Corrupt row — can't retry something unparseable. Remove it rather
// than retrying forever against a row that will never succeed.
$del = $conn->prepare("DELETE FROM notification_retry_queue WHERE retry_id = ?");
$del->bind_param('i', $row['retry_id']);
$del->execute();
$del->close();
logMessage("Discarded retry #{$row['retry_id']}: payload is not valid JSON");
continue;
}
$result = NotificationHelper::attemptDelivery($webhookUrl, $payload);
if ($result['success']) {
$del = $conn->prepare("DELETE FROM notification_retry_queue WHERE retry_id = ?");
$del->bind_param('i', $row['retry_id']);
$del->execute();
$del->close();
$succeeded++;
logMessage("Retry #{$row['retry_id']} succeeded (attempt " . ((int)$row['attempts'] + 1) . ')');
continue;
}
$newAttempts = (int)$row['attempts'] + 1;
if ($newAttempts >= (int)$row['max_attempts']) {
// Exhausted: leave the row (attempts is now == max_attempts, so the
// WHERE clause above naturally excludes it from future runs) rather
// than deleting it, so it stays visible for manual investigation.
$upd = $conn->prepare(
"UPDATE notification_retry_queue SET attempts = ?, last_error = ? WHERE retry_id = ?"
);
$upd->bind_param('isi', $newAttempts, $result['error'], $row['retry_id']);
$upd->execute();
$upd->close();
$exhausted++;
logMessage("Retry #{$row['retry_id']} exhausted after {$newAttempts} attempts: {$result['error']}");
continue;
}
$delayMinutes = nextAttemptDelayMinutes($newAttempts);
$upd = $conn->prepare(
"UPDATE notification_retry_queue
SET attempts = ?, last_error = ?, next_attempt_at = DATE_ADD(NOW(), INTERVAL ? MINUTE)
WHERE retry_id = ?"
);
$upd->bind_param('isii', $newAttempts, $result['error'], $delayMinutes, $row['retry_id']);
$upd->execute();
$upd->close();
$failed++;
logMessage("Retry #{$row['retry_id']} failed (attempt {$newAttempts}), next attempt in {$delayMinutes}m: {$result['error']}");
}
logMessage("Done: {$succeeded} succeeded, {$failed} rescheduled, {$exhausted} exhausted (of " . count($dueRows) . ' processed)');
+60 -14
View File
@@ -7,13 +7,16 @@ class NotificationHelper
{ {
// ─── Internal: fire a webhook ───────────────────────────────────────────── // ─── Internal: fire a webhook ─────────────────────────────────────────────
private static function fire(array $payload): void /**
* POST a payload to the configured Matrix webhook and report the raw
* result. Shared by fire() (best-effort, queues on failure) and
* cron/retry_failed_notifications.php (retries a previously-queued
* payload) so both use identical request handling.
*
* @return array{success: bool, http_code: ?int, error: ?string}
*/
public static function attemptDelivery(string $webhookUrl, array $payload): array
{ {
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
if (empty($webhookUrl)) {
return;
}
$ch = curl_init($webhookUrl); $ch = curl_init($webhookUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POST, 1);
@@ -21,10 +24,11 @@ class NotificationHelper
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10); curl_setopt($ch, CURLOPT_TIMEOUT, 10);
// A slow-but-not-fully-hung hookshot endpoint could otherwise add up // A slow-but-not-fully-hung hookshot endpoint could otherwise add up
// to the full CURLOPT_TIMEOUT per fire() call, and a single request // to the full CURLOPT_TIMEOUT per call, and a single request can
// can call fire() (via notifyWatchers/sendCommentNotification/etc.) // trigger more than one notification sequentially (via
// more than once sequentially — capping just the connect phase keeps // notifyWatchers/sendCommentNotification/etc.) — capping just the
// that from stacking into tens of seconds of added latency. // connect phase keeps that from stacking into tens of seconds of
// added latency.
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);
$response = curl_exec($ch); $response = curl_exec($ch);
@@ -32,12 +36,54 @@ class NotificationHelper
$curlError = curl_error($ch); $curlError = curl_error($ch);
curl_close($ch); curl_close($ch);
$id = $payload['ticket_id'] ?? '?';
if ($curlError) { if ($curlError) {
error_log("Matrix webhook cURL error for ticket #{$id}: {$curlError}"); return ['success' => false, 'http_code' => null, 'error' => $curlError];
} elseif ($httpCode < 200 || $httpCode >= 300) {
error_log("Matrix webhook failed for ticket #{$id}. HTTP {$httpCode}: {$response}");
} }
if ($httpCode < 200 || $httpCode >= 300) {
return ['success' => false, 'http_code' => $httpCode, 'error' => "HTTP {$httpCode}: {$response}"];
}
return ['success' => true, 'http_code' => $httpCode, 'error' => null];
}
/**
* Persist a failed payload for later retry by
* cron/retry_failed_notifications.php. Best-effort: a DB failure here
* must not throw back into the original (already-failed) notification
* attempt it just means this particular failure isn't retried, no
* worse than the pre-existing behavior.
*/
private static function queueForRetry(array $payload, string $error): void
{
try {
require_once dirname(__DIR__) . '/helpers/Database.php';
$conn = Database::getConnection();
$stmt = $conn->prepare(
"INSERT INTO notification_retry_queue (payload, last_error) VALUES (?, ?)"
);
$payloadJson = json_encode($payload);
$stmt->bind_param("ss", $payloadJson, $error);
$stmt->execute();
$stmt->close();
} catch (Throwable $e) {
error_log('NotificationHelper: failed to queue notification for retry: ' . $e->getMessage());
}
}
private static function fire(array $payload): void
{
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
if (empty($webhookUrl)) {
return;
}
$result = self::attemptDelivery($webhookUrl, $payload);
if ($result['success']) {
return;
}
$id = $payload['ticket_id'] ?? '?';
error_log("Matrix webhook failed for ticket #{$id}: {$result['error']}");
self::queueForRetry($payload, $result['error']);
} }
private static function notifyUsers(): array private static function notifyUsers(): array
+13 -3
View File
@@ -388,9 +388,19 @@ switch (true) {
GROUP BY user_id GROUP BY user_id
) cm ON u.user_id = cm.user_id ) cm ON u.user_id = cm.user_id
LEFT JOIN ( LEFT JOIN (
SELECT assigned_to, COUNT(*) as tickets_assigned -- Assignment date, not ticket creation date: a ticket created
FROM tickets -- outside the range but assigned within it should count, and
WHERE DATE(created_at) BETWEEN ? AND ? -- one created in-range but assigned later shouldn't (until it
-- is). Derived from audit_log's 'assign' events since tickets
-- has no assigned_at column; COUNT(DISTINCT ...) so a ticket
-- reassigned more than once to the same user in-range still
-- counts once.
SELECT
CAST(JSON_UNQUOTE(JSON_EXTRACT(details, '$.assigned_to')) AS UNSIGNED) as assigned_to,
COUNT(DISTINCT entity_id) as tickets_assigned
FROM audit_log
WHERE action_type = 'assign' AND entity_type = 'ticket'
AND DATE(created_at) BETWEEN ? AND ?
GROUP BY assigned_to GROUP BY assigned_to
) ta ON u.user_id = ta.assigned_to ) ta ON u.user_id = ta.assigned_to
LEFT JOIN ( LEFT JOIN (
+2 -1
View File
@@ -37,6 +37,7 @@ class ApiKeyAuth
{ {
$this->keyContext = [ $this->keyContext = [
'scope' => $keyData['scope'] ?? 'read_write', 'scope' => $keyData['scope'] ?? 'read_write',
'see_all_visibility' => !empty($keyData['see_all_visibility']),
'key_name' => $keyData['key_name'] ?? null, 'key_name' => $keyData['key_name'] ?? null,
'created_by' => $keyData['created_by'] ?? null, 'created_by' => $keyData['created_by'] ?? null,
'api_key_id' => $keyData['api_key_id'] ?? null, 'api_key_id' => $keyData['api_key_id'] ?? null,
@@ -46,7 +47,7 @@ class ApiKeyAuth
/** /**
* Get the context of the authenticated API key. * Get the context of the authenticated API key.
* *
* @return array|null ['scope', 'key_name', 'created_by', 'api_key_id'] or null * @return array|null ['scope', 'see_all_visibility', 'key_name', 'created_by', 'api_key_id'] or null
*/ */
public function getKeyContext(): ?array public function getKeyContext(): ?array
{ {
+14
View File
@@ -0,0 +1,14 @@
-- Add a nullable thumbnail_filename column to ticket_attachments so an image
-- upload can store a separately-generated, resized preview alongside the
-- full-size original. NULL means no thumbnail exists (non-image, GD
-- unavailable at upload time, or an attachment uploaded before this existed)
-- and callers fall back to the full-size original.
--
-- scripts/cleanup_orphan_uploads.php's orphan lookup is updated in the same
-- change to also match thumbnail_filename, so generated thumbnails aren't
-- swept up as orphans.
--
-- Safe to re-run.
ALTER TABLE `ticket_attachments`
ADD COLUMN IF NOT EXISTS `thumbnail_filename` varchar(255) DEFAULT NULL AFTER `filename`;
@@ -0,0 +1,13 @@
-- Restrict Bearer API keys to public-visibility tickets by default (#70).
--
-- Previously any 'read'-scope key bypassed ticket visibility entirely —
-- Confidential and Internal tickets were readable by any key, regardless
-- of who it was issued to. see_all_visibility is an explicit opt-in an
-- admin sets per-key when a key genuinely needs to see non-public tickets;
-- it defaults to 0 (public-only) for both new and existing keys, since the
-- prior blanket-access behavior is the thing being restricted.
--
-- Safe to re-run.
ALTER TABLE `api_keys`
ADD COLUMN IF NOT EXISTS `see_all_visibility` tinyint(1) NOT NULL DEFAULT 0 AFTER `scope`;
@@ -0,0 +1,20 @@
-- Queue for Matrix webhook notifications that failed to send (#78).
--
-- NotificationHelper::fire() previously logged a failed webhook post via
-- error_log() only, with no retry and no persistent record — once a
-- notification failed, it was gone. Failed payloads are now queued here and
-- retried by cron/retry_failed_notifications.php with exponential backoff.
--
-- Safe to re-run.
CREATE TABLE IF NOT EXISTS `notification_retry_queue` (
`retry_id` int(11) NOT NULL AUTO_INCREMENT,
`payload` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL CHECK (json_valid(`payload`)),
`attempts` int(11) NOT NULL DEFAULT 0,
`max_attempts` int(11) NOT NULL DEFAULT 6,
`next_attempt_at` timestamp NULL DEFAULT current_timestamp(),
`last_error` varchar(500) DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`retry_id`),
KEY `idx_next_attempt` (`next_attempt_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
+15 -4
View File
@@ -19,9 +19,11 @@ class ApiKeyModel
* @param int $createdBy User ID who created the key * @param int $createdBy User ID who created the key
* @param int|null $expiresInDays Number of days until expiration (null for no expiration) * @param int|null $expiresInDays Number of days until expiration (null for no expiration)
* @param string $scope Access scope: 'read' or 'read_write' (default 'read_write') * @param string $scope Access scope: 'read' or 'read_write' (default 'read_write')
* @param bool $seeAllVisibility If true, the key bypasses ticket visibility (Confidential/
* Internal included); defaults to false (public tickets only)
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'scope', 'error' * @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'scope', 'error'
*/ */
public function createKey($keyName, $createdBy, $expiresInDays = null, $scope = 'read_write') public function createKey($keyName, $createdBy, $expiresInDays = null, $scope = 'read_write', $seeAllVisibility = false)
{ {
// Validate the requested scope — only the two known values are allowed // Validate the requested scope — only the two known values are allowed
if (!in_array($scope, ['read', 'read_write'], true)) { if (!in_array($scope, ['read', 'read_write'], true)) {
@@ -47,11 +49,12 @@ class ApiKeyModel
} }
// Insert API key into database // Insert API key into database
$seeAllVisibilityInt = $seeAllVisibility ? 1 : 0;
$stmt = $this->conn->prepare( $stmt = $this->conn->prepare(
"INSERT INTO api_keys (key_name, key_hash, key_prefix, scope, created_by, expires_at) " "INSERT INTO api_keys (key_name, key_hash, key_prefix, scope, see_all_visibility, created_by, expires_at) "
. "VALUES (?, ?, ?, ?, ?, ?)" . "VALUES (?, ?, ?, ?, ?, ?, ?)"
); );
$stmt->bind_param("ssssis", $keyName, $keyHash, $keyPrefix, $scope, $createdBy, $expiresAt); $stmt->bind_param("ssssiis", $keyName, $keyHash, $keyPrefix, $scope, $seeAllVisibilityInt, $createdBy, $expiresAt);
if ($stmt->execute()) { if ($stmt->execute()) {
$keyId = $this->conn->insert_id; $keyId = $this->conn->insert_id;
@@ -63,6 +66,7 @@ class ApiKeyModel
'key_prefix' => $keyPrefix, 'key_prefix' => $keyPrefix,
'key_id' => $keyId, 'key_id' => $keyId,
'scope' => $scope, 'scope' => $scope,
'see_all_visibility' => $seeAllVisibility,
'expires_at' => $expiresAt 'expires_at' => $expiresAt
]; ];
} else { } else {
@@ -114,6 +118,13 @@ class ApiKeyModel
$keyData['scope'] = 'read_write'; $keyData['scope'] = 'read_write';
} }
// Unlike scope's backward-compatible fallback above, an un-migrated or
// null see_all_visibility defaults to the RESTRICTIVE value (public
// tickets only) — this column exists specifically to lock down a
// previously-unrestricted default, so a missing value must not fall
// back to the permissive behavior it's replacing.
$keyData['see_all_visibility'] = !empty($keyData['see_all_visibility']);
// Check expiration // Check expiration
if ($keyData['expires_at'] !== null) { if ($keyData['expires_at'] !== null) {
$expiresAt = strtotime($keyData['expires_at']); $expiresAt = strtotime($keyData['expires_at']);
+9 -4
View File
@@ -68,14 +68,19 @@ class AttachmentModel
/** /**
* Add a new attachment record * Add a new attachment record
*
* @param string|null $thumbnailFilename Stored filename of a generated preview
* thumbnail, or null if none was generated
* (non-image, GD unavailable, etc.) callers
* fall back to the full-size original.
*/ */
public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy) public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy, $thumbnailFilename = null)
{ {
$sql = "INSERT INTO ticket_attachments (ticket_id, filename, original_filename, file_size, mime_type, uploaded_by) $sql = "INSERT INTO ticket_attachments (ticket_id, filename, thumbnail_filename, original_filename, file_size, mime_type, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?)"; VALUES (?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
$stmt->bind_param("sssisi", $ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy); $stmt->bind_param("ssssisi", $ticketId, $filename, $thumbnailFilename, $originalFilename, $fileSize, $mimeType, $uploadedBy);
$result = $stmt->execute(); $result = $stmt->execute();
if ($result) { if ($result) {
+2 -1
View File
@@ -20,7 +20,8 @@ class AuditLogModel
private const VALID_ACTION_TYPES = [ private const VALID_ACTION_TYPES = [
'create', 'update', 'delete', 'view', 'security_event', 'create', 'update', 'delete', 'view', 'security_event',
'login', 'logout', 'assign', 'unassign', 'comment', 'mention', 'login', 'logout', 'assign', 'unassign', 'comment', 'mention',
'revoke', 'attachment_upload', 'attachment_delete', 'bulk_update' 'revoke', 'attachment_upload', 'attachment_delete', 'bulk_update',
'watch', 'unwatch'
]; ];
/** @var array Allowed entity types for filtering */ /** @var array Allowed entity types for filtering */
+1 -1
View File
@@ -148,7 +148,7 @@ class StatsModel
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY priority FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY priority
UNION ALL UNION ALL
SELECT 'status' as type, status as label, COUNT(*) as count SELECT 'status' as type, status as label, COUNT(*) as count
FROM tickets t WHERE ($visSQL) GROUP BY status FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY status
UNION ALL UNION ALL
SELECT 'category' as type, category as label, COUNT(*) as count SELECT 'category' as type, category as label, COUNT(*) as count
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY category"; FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY category";
+8 -6
View File
@@ -59,10 +59,11 @@ try {
exit(1); exit(1);
} }
// Prepared lookup: does any attachment row reference this stored filename? // Prepared lookup: does any attachment row reference this stored filename,
// Stored filenames are globally unique (uniqid), so filename alone is sufficient // either as the original file or as its generated preview thumbnail? Both
// and safe — a match in any ticket means the file is a real attachment. // are globally unique (uniqid-derived), so filename alone is sufficient and
$lookup = $conn->prepare('SELECT 1 FROM ticket_attachments WHERE filename = ? LIMIT 1'); // safe — a match in any ticket means the file is a real, referenced file.
$lookup = $conn->prepare('SELECT 1 FROM ticket_attachments WHERE filename = ? OR thumbnail_filename = ? LIMIT 1');
if ($lookup === false) { if ($lookup === false) {
logMessage('FATAL ERROR: could not prepare lookup statement: ' . $conn->error); logMessage('FATAL ERROR: could not prepare lookup statement: ' . $conn->error);
exit(1); exit(1);
@@ -101,8 +102,9 @@ foreach (new DirectoryIterator($uploadRoot) as $entry) {
continue; continue;
} }
// Keep the file if any attachment row references it. // Keep the file if any attachment row references it (as the
$lookup->bind_param('s', $filename); // original or as its thumbnail).
$lookup->bind_param('ss', $filename, $filename);
$lookup->execute(); $lookup->execute();
$hasRow = $lookup->get_result()->num_rows > 0; $hasRow = $lookup->get_result()->num_rows > 0;
+5 -2
View File
@@ -280,8 +280,11 @@ include __DIR__ . '/layout_header.php';
// default: with no `status` param the controller falls back to the viewer's // default: with no `status` param the controller falls back to the viewer's
// default_status_filters preference, which can be anything, so the resulting // default_status_filters preference, which can be anything, so the resulting
// list would not necessarily match what the chart counted. StatsModel builds // list would not necessarily match what the chart counted. StatsModel builds
// by_priority and by_category with `status != 'Closed'`, while by_status spans // by_priority, by_status, and by_category all with `status != 'Closed'`
// every status — so only the priority and category charts pin the open set. // closed tickets accumulate indefinitely and would otherwise dominate every
// breakdown over time — so chartPriority/chartCategory pin the open set
// explicitly, while chartStatus's clicked label is itself already one of
// the non-Closed statuses.
function openStatuses() { function openStatuses() {
var all = window.TICKET_STATUSES || ['Open', 'Pending', 'In Progress', 'Closed']; var all = window.TICKET_STATUSES || ['Open', 'Pending', 'In Progress', 'Closed'];
return all.filter(function(s) { return s !== 'Closed'; }).join(','); return all.filter(function(s) { return s !== 'Closed'; }).join(',');
+6
View File
@@ -32,6 +32,8 @@ function getEventIcon(string $actionType): string
'status_change' => '[!]', 'status_change' => '[!]',
'attachment' => '[^]', 'attachment' => '[^]',
'delete' => '[x]', 'delete' => '[x]',
'watch' => '[o]',
'unwatch' => '[o]',
default => '[*]', default => '[*]',
}; };
} }
@@ -53,6 +55,10 @@ function formatAction(array $event): string
return 'uploaded a file'; return 'uploaded a file';
case 'delete': case 'delete':
return 'deleted a comment'; return 'deleted a comment';
case 'watch':
return 'started watching this ticket';
case 'unwatch':
return 'stopped watching this ticket';
case 'assign': case 'assign':
if (is_array($det) && isset($det['assigned_to']['to'])) { if (is_array($det) && isset($det['assigned_to']['to'])) {
$to = $det['assigned_to']['to'] ?: 'Unassigned'; $to = $det['assigned_to']['to'] ?: 'Unassigned';
+27 -5
View File
@@ -45,10 +45,18 @@ include __DIR__ . '/../../views/layout_header.php';
<option value="read">read</option> <option value="read">read</option>
</select> </select>
</div> </div>
<div class="lt-form-group" style="flex:1;margin:0">
<label class="lt-label" style="display:flex;align-items:center;gap:0.4rem;cursor:pointer">
<input type="checkbox" id="keySeeAllVisibility">
See all visibility
</label>
</div>
<button type="submit" class="lt-btn lt-btn-primary" style="margin-bottom:0">GENERATE KEY</button> <button type="submit" class="lt-btn lt-btn-primary" style="margin-bottom:0">GENERATE KEY</button>
</form> </form>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem"> <p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">
Scope: <strong>read</strong> = GET only; <strong>read_write</strong> = create/comment/close. Scope: <strong>read</strong> = GET only; <strong>read_write</strong> = create/comment/close.
By default a key only sees <strong>public</strong>-visibility tickets check
<strong>See all visibility</strong> only if this key genuinely needs Confidential/Internal tickets too.
</p> </p>
<!-- New key display (hidden by default) --> <!-- New key display (hidden by default) -->
@@ -74,6 +82,7 @@ include __DIR__ . '/../../views/layout_header.php';
<th scope="col">Name</th> <th scope="col">Name</th>
<th scope="col">Key Prefix</th> <th scope="col">Key Prefix</th>
<th scope="col">Scope</th> <th scope="col">Scope</th>
<th scope="col">Visibility</th>
<th scope="col">Created By</th> <th scope="col">Created By</th>
<th scope="col">Created</th> <th scope="col">Created</th>
<th scope="col">Expires</th> <th scope="col">Expires</th>
@@ -86,7 +95,7 @@ include __DIR__ . '/../../views/layout_header.php';
<?php <?php
$apiKeysList = $apiKeys['keys'] ?? []; $apiKeysList = $apiKeys['keys'] ?? [];
if (empty($apiKeysList)) : ?> if (empty($apiKeysList)) : ?>
<tr><td colspan="9" class="lt-empty">No API keys found. Generate one above.</td></tr> <tr><td colspan="10" class="lt-empty">No API keys found. Generate one above.</td></tr>
<?php else : <?php else :
foreach ($apiKeysList as $key) : ?> foreach ($apiKeysList as $key) : ?>
<?php <?php
@@ -103,6 +112,13 @@ include __DIR__ . '/../../views/layout_header.php';
<span class="lt-status lt-status-open"><?= htmlspecialchars($scope) ?></span> <span class="lt-status lt-status-open"><?= htmlspecialchars($scope) ?></span>
<?php endif ?> <?php endif ?>
</td> </td>
<td data-label="Visibility">
<?php if (!empty($key['see_all_visibility'])) : ?>
<span class="lt-status lt-status-open" title="Bypasses ticket visibility — sees Confidential/Internal tickets too">all</span>
<?php else : ?>
<span class="lt-status lt-status-closed" title="Only sees public-visibility tickets">public only</span>
<?php endif ?>
</td>
<td data-label="Created By" class="lt-text-xs"><?= htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown') ?></td> <td data-label="Created By" class="lt-text-xs"><?= htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown') ?></td>
<td data-label="Created" class="lt-text-xs lt-text-muted"><?= date('Y-m-d H:i', strtotime($key['created_at'])) ?></td> <td data-label="Created" class="lt-text-xs lt-text-muted"><?= date('Y-m-d H:i', strtotime($key['created_at'])) ?></td>
<td data-label="Expires" class="lt-text-xs <?= $expired ? 'lt-text-danger' : 'lt-text-cyan' ?>"> <td data-label="Expires" class="lt-text-xs <?= $expired ? 'lt-text-danger' : 'lt-text-cyan' ?>">
@@ -239,11 +255,17 @@ document.addEventListener('click', function (e) {
document.getElementById('generateKeyForm').addEventListener('submit', function (e) { document.getElementById('generateKeyForm').addEventListener('submit', function (e) {
e.preventDefault(); e.preventDefault();
var keyName = document.getElementById('keyName').value.trim(); var keyName = document.getElementById('keyName').value.trim();
var expiresIn = document.getElementById('expiresIn').value; var expiresIn = document.getElementById('expiresIn').value;
var keyScope = document.getElementById('keyScope').value; var keyScope = document.getElementById('keyScope').value;
var seeAllVisibility = document.getElementById('keySeeAllVisibility').checked;
if (!keyName) { lt.toast.error('Please enter a key name'); return; } if (!keyName) { lt.toast.error('Please enter a key name'); return; }
lt.api.post('/api/generate_api_key.php', { key_name: keyName, expires_in_days: expiresIn || null, scope: keyScope }) lt.api.post('/api/generate_api_key.php', {
key_name: keyName,
expires_in_days: expiresIn || null,
scope: keyScope,
see_all_visibility: seeAllVisibility
})
.then(function (data) { .then(function (data) {
if (data.success) { if (data.success) {
document.getElementById('newKeyValue').value = data.api_key; document.getElementById('newKeyValue').value = data.api_key;