The hosts were upgraded to PHP 8.4, where mysqli::ping() is deprecated
(auto-reconnect was removed in 8.2). Database::getConnection() called it on
every reused connection, and api/ticket_dependencies.php's custom error
handler treated the deprecation as a fatal 500 ('A server error occurred'),
breaking the ticket Dependencies tab.
- Database.php: remove the redundant ping()/reconnect check (connection is
request-scoped; no liveness check needed on PHP 8.2+).
- ticket_dependencies.php: only abort on genuine errors; log notices/
warnings/deprecations and continue, so a future deprecation can't 500 it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- create_ticket_api.php: remove the wrong CREATE TABLE stub that broke a
fresh DB; generate collision-safe ticket_ids so a genuine id collision
isn't misreported as a duplicate and a hw alert dropped; stop leaking
raw DB errors; correct a reopen comment that falsely claimed refreshed
sensor data
- manage_recurring.php: fix next-run so create/edit no longer skips the
current period (monthly day-of-month this month, daily today if time
not passed, correct ISO weekday, month-length clamp); only recompute
on schedule changes to avoid double-fire
- export_tickets.php, audit_log.php: neutralize CSV formula injection
- revoke_api_key.php, generate_api_key.php: correct HTTP status codes and
stop the catch clobbering specific 4xx codes
- health.php: stop leaking PHP version / extension names / paths to
unauthenticated callers
- watch_ticket.php: define $data before use
- manage_templates/recurring/custom_fields: add audit logging for CRUD;
add recurring_ticket + custom_field to the audit entity whitelist
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ticket_dependencies.php: pass current user id/groups/is_admin into the
visibility-filtered DependencyModel methods; drop (int) casts that
stripped leading zeros from varchar ticket_ids
- update_ticket.php: authorize visibility changes (admin or creator only);
enforce requires_comment transitions server-side (400 + requires_comment
flag so the client can prompt-and-retry); return proper 401/400/403
- add_comment.php: take commenter name from the session not the client
(anti-spoofing); validate parent_comment_id belongs to the ticket;
reject empty comments; pass ticket visibility to notifications so
non-public comment bodies aren't leaked
- add_comment/update_comment/bulk_operation: validate CSRF for all
state-changing methods, not just POST
- bootstrap.php: return the current CSRF token on rejection and never
rotate it on a rejected request, so a desynced client can auto-recover
- correct auth->401 and validation->400 status codes across these endpoints
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Medium:
- create_ticket_api.php: environment tags were parsed with explode('][') which
left brackets on the first/last tag so the whitelist never matched, dropping
the env tag from the dedup hash — a [production] and [staging] issue with
otherwise-identical components could collide onto one ticket. Use a
bracket-aware regex.
- CommentModel::getThreadedCommentsPaged only fetched DIRECT children of root
comments, so when pagination is active, nested replies at depth 2-3 vanished
from the thread. Expand replies level-by-level (bounded to depth 3).
- StatsModel::getTicketsByAssignee ignored the visibility filter the rest of the
stats apply, so a non-admin's "by assignee" widget counted (leaked) confidential
tickets. Thread the same filter through.
- watch_ticket.php GET path returned watch state / watcher names / count for any
ticket with no access check (the POST path checks it) — added canUserAccessTicket.
- dashboard.js kanban: every card rendered as P4 because the [class*="lt-p"]
selector never matched the lt-badge-p1 class and the fallback didn't strip "P".
Extract the digit directly.
Low:
- audit_log.php CSV: "Log ID" column was always blank ($log['log_id'] vs the real
audit_id column). Use audit_id.
- check_duplicates.php: the graceful-degradation try/catch only covered the throw
path; guard the false-return (non-exception mysqli) path too.
- notifications.php: owner-who-is-also-@mentioned got two notifications for one
comment; drop the duplicate comment row when a mention covers the same comment.
- dashboard.js hover preview rendered "PP1" (doubled prefix); strip the leading P.
- markdown.js: code/inline-code restore used string replace, so $&, $$, $`, $' in
user code were treated as replacement patterns; use a function replacer. Also
removed an unused loop var.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- create_ticket_api.php: validate status (against TICKET_STATUSES) and
priority (numeric 1-5). A non-numeric priority previously cast to 0 and
escalated the ticket below P1 on the dedup/update path.
- manage_workflows.php: reject empty/invalid from_status/to_status on POST
and PUT (must be valid ticket statuses) so the workflow table can't be
populated with bogus transitions.
- TicketModel::getAllTickets: COUNT(*) OVER() rides on returned rows, so a
page past the last row returned total/pages = 0. Fall back to a direct
COUNT when an over-range page yields no rows, keeping pager math correct.
- DashboardView: stop double-escaping category/type/assigned active-filter
labels (they were htmlspecialchars'd into the label and again at output,
rendering R&D as R&D); output escaping is retained.
- check_duplicates.php / NotificationHelper::notifyWatchers: wrap the DB
lookups in try/catch so a failed prepare/query degrades gracefully
(advisory dup-check returns none; best-effort watcher notify is skipped)
instead of fataling the request. Works whether mysqli throws or returns
false. (manage_* endpoints already have a top-level try/catch.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
XSS / security:
- markdown.js: sanitize footnote labels to a safe slug before using them in
id/href attributes. Labels are captured before the HTML-escape pass, so a
label like x"><img onerror=...> broke out → stored XSS (the earlier quote-
escape fix didn't cover this path). Verified neutralized.
- RateLimitMiddleware: only trust X-Forwarded-For / X-Real-IP when REMOTE_ADDR
is a configured trusted proxy, and use the rightmost (proxy-appended) entry.
Previously any client could rotate XFF to escape the per-IP rate limit.
- .env.example: document TRUSTED_PROXIES so fresh deploys aren't fail-open on
the Authelia forward-auth spoofing protection.
Correctness:
- notifications.php: my previous assigned-to LIKE fix anchored only on '}', so
BULK assignments (logged {"assigned_to":N,"bulk_operation_id":..}) produced
no "assigned to you" notification — now matches both '}' and ',' delimiters.
- notifications.php: implement the documented @mention notifications (query
action_type='mention' rows for the current user); they were never delivered.
- NotificationHelper::notifyWatchers: guard unchecked prepare() so a missing
ticket_watchers table can't fatal the request after its DB write committed.
- AuditLogModel::getTicketTimeline: JSON_UNQUOTE the extracted ticket_id so
comment events actually match (string vs JSON-number comparison never did).
- AuditLogModel/audit_log.php: CSV export no longer silently truncates to the
1000-row UI cap; uses a dedicated higher export limit.
- DashboardView: quick-preview drawer read .ticket-link from the title cell
(which has none), so the title was always blank — use the cell text.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Trusted-proxy hardening (defense-in-depth for Authelia forward-auth):
- AuthMiddleware now only honors Remote-* identity headers when REMOTE_ADDR
is in a configured TRUSTED_PROXIES allowlist; otherwise it refuses with 403
and logs an 'untrusted_proxy' security event. Previously anything that could
reach PHP directly could spoof Remote-User/Remote-Groups and log in as admin.
- New config TRUSTED_PROXIES (comma-separated, from .env). Empty = enforcement
off, so this is backward compatible until the allowlist is set on a host.
Requirements checks (so a PHP upgrade dropping an extension can't silently
break features like avatars again):
- config/requirements.php: single source of truth for min PHP version and
required extensions (ldap, mysqli, curl, mbstring, fileinfo, json).
- scripts/check_requirements.php: CI script that fails the build if the
environment doesn't satisfy them.
- New 'requirements' CI job installs those extensions and runs the check;
deploy now depends on it.
- api/health.php: adds php_extensions + php_version checks so production
monitoring surfaces the drift (returns 503 if a required extension is gone).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verified, high-confidence fixes from a project-wide review:
- markdown.js: escape " and ' in the HTML-escape step. User-controlled
image/link URLs and alt text were interpolated into "..." attributes
without quote escaping, allowing attribute breakout and injected event
handlers (stored XSS, only mitigated by CSP). Flagged independently by
two reviewers.
- cron/create_recurring_tickets.php & cron/cleanup_ratelimit.php: a
mangled crontab example inside the docblock contained */ which closed
the comment early, causing a fatal parse error — both cron jobs never
ran. Rewrote the docblocks without a literal */.
- update_ticket.php: validate visibility BEFORE the core DB write so an
invalid payload can't leave the ticket updated while the request reports
failure (which also skipped the audit delta and stats cache invalidation).
- watch_ticket.php: GET watcher_count was capped at 6 (count of a LIMIT 6
list); use an unbounded COUNT(*) so it matches the POST path.
- notifications.php: "assigned to me" LIKE pattern lacked a trailing
delimiter, so user 12 also matched 120/123/etc.; anchor with }.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
user_avatar.php wrote a ".none" sentinel whenever it failed to obtain
avatar bytes, conflating "LDAP errored/timed out" with "user has no
avatar". A brief lldap blip (restart, slow response past the 3s timeout,
network hiccup) therefore cached a 404 for the full AVATAR_CACHE_TTL
(1h default), leaving avatars broken long after lldap recovered.
Track whether the LDAP query actually completed (`$ldapQueryOk`) and only
write the negative-cache sentinel when lldap genuinely answered with no/
invalid avatar. On error/timeout, leave no sentinel so the lookup retries
once lldap is healthy again.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- index.php: replace SQL string interpolation with concatenation + explicit
(int) casts for LIMIT/OFFSET; add nosemgrep for tainted-sql false positive
(WHERE clause built from hardcoded fragments with bound params only)
- api/upload_attachment.php: add realpath() path-traversal guard after mkdir
- api/user_avatar.php: make (int) cast explicit at cache-path construction;
add nosemgrep for tainted-filename false positive (integer-only input)
- assets/js/ticket.js: add nosemgrep for insertAdjacentHTML — all dynamic
content already escaped via lt.escHtml() before insertion
- .gitea/workflows/security.yml: exclude echoed-request rule globally —
all echo in API context is json_encode() output, not HTML; htmlentities()
fix semgrep suggests would corrupt JSON responses
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
api/export_tickets.php: getAllTickets() was called without $currentUser,
so visibility filtering was skipped — any authenticated user could export
all tickets including confidential/internal ones.
api/user_preferences.php: the single-preference setcookie() call was
missing httponly/secure flags (batch path had them correctly). Also cast
preference values to string before passing to setPreference(string).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
AuditLogModel::logCommentCreate logs comments with action_type='comment'
not 'create'. The notification query was filtering on action_type='create'
only, so comment events on watched/owned tickets were never surfaced.
Widen the filter to IN ('comment', 'create') to match the actual logged
values while staying compatible with any legacy entries.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
assign_ticket.php: preserve string ticket ID (ctype_digit validation)
instead of (int) cast for consistent audit logging and URL generation.
delete_attachment.php: use string ticket_id from DB for the upload
directory path — (int) cast was stripping leading zeros, causing
the wrong path (/uploads/123456/) instead of /uploads/000123456/.
Also pass raw string to getTicketById() to let TicketModel handle
type coercion.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Preserve source ticket ID as string (ctype_digit validation)
instead of int-casting with (int)
- Use $sourceTicket['ticket_id'] (canonical DB form) when creating
the relates_to dependency and audit log entry; avoids storing
"123456" instead of "000123456" which breaks string-based
depends_on_id lookups in DependencyModel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- TicketModel: add deleteTicket() that removes all child records
(comments, watchers, dependencies, attachments, custom fields)
then deletes the ticket and cleans up physical attachment files
- BulkOperationsModel: add bulk_delete case to processBulkOperation()
so the "Bulk Delete" UI button actually works instead of silently
failing with N failures
- bulk_operation.php: validate operation_type against whitelist to
reject unknown operations early with a proper error
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- dashboard.js: use String(cb.value) instead of parseInt() in
getSelectedTicketIds() so zero-padded IDs like 000123456 are
preserved when sent to bulk_operation.php
- DashboardView.php: remove (int) cast on data-ticket-id attribute
for quick-status button; was stripping leading zeros
- TicketView.php: remove (int) cast on export URL ticket_id param
- update_ticket.php: preserve ticket_id as string via trim((string)...)
- add_comment.php: preserve ticket_id as string; validate with
ctype_digit instead of (int) cast so comments are stored with the
canonical zero-padded ID matching the tickets table
- export_tickets.php: validate singleId as string to avoid stripping
leading zeros in the export endpoint
- notifications.php: preserve ticket_id strings in URLs and ticket
ownership checks; index myTicketIds by both int and string forms
for robust lookup regardless of how audit_log stored the ID
- TicketController.php: fix inline dependency insert — column was
wrong (depends_on_ticket_id → depends_on_id) and bind types were
wrong ("iii" → "ssi"); feature was silently broken
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
bulk_operation.php: ticket ID validation was converting IDs to int then back
to string, so '000123456' became '123456' which never matched the DB VARCHAR
key, silently rejecting ~11% of tickets from bulk operations. Now validates
with ctype_digit() to preserve leading zeros.
TicketModel::getTicketsByIds(): changed intval() to strval() and bind type
'i' to 's' so VARCHAR ticket_id columns are queried consistently as strings.
DashboardController::getCategoriesAndTypes(): added null check on query
result before calling fetch_assoc() to prevent TypeError if query fails.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- add_comment.php: include user_id in response for avatar rendering
- ticket.js: add buildCommentElement() helper that matches server-rendered
comment structure (avatar, edit/delete buttons, textarea); use it in
addComment() and submitReply() so new comments show the avatar immediately
- AuditLogModel: logCommentCreate uses action_type='comment' not 'create'
- TicketView: formatAction handles entity_type='comment' with action_type='create'
for existing DB records; prevents "created this ticket" showing for comments
- update_ticket.php: remove owner/assignee restriction so any authenticated
team member can update ticket status and fields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- manage_recurring.php calculateNextRun(): expand monthly cap from 28→31
with proper last-day-of-month clamping (matches model fix); use split
with ':00' append to handle malformed time strings without crashing;
fix weekly day array to start at index 1 (not 0) so day=0 never maps
to empty string and blows up DateTime
- RecurringTicketModel::calculateNextRunTime(): same weekly day array fix
(start at index 1) to eliminate '' → DateTime exception on day=0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- TicketView.php: Show 3 lt-skeleton-card placeholders in the comment list
while "Load more" fetches; skeletons are removed on resolve or error
- ticket.css: Add .comment-skeleton margin spacing
- WorkflowDesignerView.php + manage_workflows.php: Prevent creating/editing
status transitions where from_status === to_status (client + server check)
- RecurringTicketsView.php: Expand monthly day picker from 28 to 31 days
(days 29-31 labelled "last day in short months")
- RecurringTicketModel.php: Clamp monthly schedule day to last day of target
month using format('t') instead of hard-capping at 28
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- download_attachment.php: path traversal check used strpos() without
trailing DIRECTORY_SEPARATOR, allowing /uploads_evil/* to pass when
upload dir is /uploads — now checks realPath + DIRECTORY_SEPARATOR prefix
- bulk_operation.php: $conn->close() was called before StatsModel($conn)
construction; moved close() inside each branch to after all DB use
- upload_attachment.php: ticket ID validated as /^\d{9}$/ (exactly 9
digits) breaking all tickets below ID 1,000,000,000 — changed to
/^\d+$/ for any positive integer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Added session_status() === PHP_SESSION_NONE guard to six API files
(custom_fields, revoke_api_key, manage_templates, generate_api_key,
get_template, manage_recurring) that called bare session_start() after
RateLimitMiddleware had already started the session
- Registered /api/notifications.php and /api/user_avatar.php in index.php
router (were missing, served only by direct file access)
- Complete README rewrite: remove all Discord references (Matrix/hookshot
is the only external notification method), add hwmonDaemon API docs,
document all TDS v1.2 features (kanban, charts, SLA, command palette,
notification bell, watcher avatars, @mention, etc.), fix keyboard
shortcuts table, add Matrix/LDAP env vars to setup section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extract expression args to local variables before bind_param (PHP 8 requirement)
- Guard session_start with session_status check in manage_workflows
- Remove redundant session_start from bulk_operation (RateLimitMiddleware starts it)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
StatsModel::invalidateCache() was never called from any API, so the
60s cached stats persisted after bulk assign/status/priority changes,
ticket updates, assignments, and clones. Dashboard tiles showed stale
counts until the TTL expired.
Added invalidation to the four APIs that affect dashboard stat tiles:
- bulk_operation.php: after successful bulk assign/status/priority
- assign_ticket.php: after successful reassignment
- update_ticket.php: after any successful ticket update
- clone_ticket.php: after successful clone (open_tickets changes)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- notifications.php: audit_log PK is audit_id not log_id; alias all
three queries with audit_id AS log_id to fix 500 error
- DashboardView: avg resolution time now picks best unit automatically
(min < 1h, hr < 48h, days < 14d, wks otherwise) with full hours
shown in title tooltip; adds lt-stat-unit CSS for the suffix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The $statusSql double-quoted string contained '%"status":%' which caused
PHP to terminate the string at the inner double quotes, resulting in a
parse error (unexpected identifier 'status') on the beta server.
Also cleared stale stats cache that stored by_assignee in old name=>count
map format instead of the current array-of-objects format.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both keys were silently dropped on batch save (the for-loop just
continued on unknown keys). timezone is sent by saveSettings() and
notif_last_seen is written by the notifications mark-read endpoint.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DashboardView.php: wrap performAdvancedSearch in a closure so it is
resolved at event-fire time rather than listener-registration time
(advanced-search.js loads later via pageScripts so the bare identifier
reference caused ReferenceError).
DashboardView.php: reset sort URL to page=1 so sorting all pages
instead of staying on the current page.
dashboard.js: add missing save-settings and close-settings cases to
the click delegation handler (were removed in a prior session under
the assumption they were in dashboard.js, but they were not).
notifications.php: replace JSON_EXTRACT-based comment join (not
universally supported) with a two-step PHP filter: fetch owner/watcher
ticket IDs first, then filter raw comment rows in PHP. Also fix the
status change LIKE pattern to match the actual logTicketUpdate format
{"status": {"from": ..., "to": ...}}.
SecurityHeadersMiddleware.php: add https://cdn.jsdelivr.net to
connect-src so Chart.js source maps load without CSP violations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
notifications.php: comment notifications never fired because the query
used action_type='comment'/entity_type='ticket' but logCommentCreate
logs action_type='create'/entity_type='comment'. Fix query to match
actual log format and extract ticket_id from details JSON.
notifications.php: status change notification titles always showed
"? → ?" because code read details.old_value/new_value but logTicketUpdate
stores the delta as {"status": {"from": ..., "to": ...}}.
base.css: move .is-hidden to base.css (global) — it was only defined in
ticket.css, so on the dashboard the ticket-preview popup had no hide
rule applied and was visible in the DOM at all times.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace style="text-align:center" with .lt-text-center utility class in
WorkflowDesignerView, CustomFieldsView, error_403, error_404, DashboardView JS string
- Replace style="margin-top:..." with .lt-mt-sm utility in WorkflowDesignerView
- Switch comment-edit-raw data-store textareas to .is-hidden class (TicketView PHP
+ JS-rendered; ticket.js template literal) — these are never shown, only read via .value
- Add aria-describedby="visibilityGroupsHint" + id on hint <p> in CreateTicketView
- Fix bind_param type string bug in manage_workflows.php PUT handler: 'ssiiiii' → 'ssiiii'
(7 type chars for 6 params caused binding error on workflow transition updates)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- TicketView: SLA banner now shows live HH:MM:SS elapsed + countdown via JS setInterval
(previously showed static hours from PHP)
- TicketView: Markdown toggles in comment form replaced with lt-toggle switches
- layout_header: In-app notification bell (🔔) with dropdown panel for all users
- layout_footer: Notification JS — polls /api/notifications.php every 60s, badge count,
mark-all-read, panel open/close with Escape/outside-click
- api/notifications.php (new): Returns assign/comment/status-change events from audit_log
for current user's tickets and watched tickets; mark-read via user_preferences
- DashboardView: Ticket preview right drawer — Ctrl+click title or ⊙ peek button
opens lt-drawer-right with ticket summary extracted from table row DOM
- DashboardView: lt.sortable wired on all 4 kanban columns (group='kanban')
Cross-column drag = status change via POST /api/update_ticket.php with optimistic UI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- watch_ticket.php GET now returns watcher list (up to 6 users) for avatar group
- TicketView: watcher avatar group rendered next to WATCH button, refreshes on toggle
- Rewrite renderDependencies/renderDependents to use TDS lt-kv-grid/lt-badge/lt-btn classes
- renderDependencies: show lt-alert--warning blocker banner when blocked_by has open tickets
- Fix ALL hardcoded ?v=20260327 asset version strings in CreateTicketView + all admin views
- base.css: fix .lt-nav-dropdown-menu hardcoded background → var(--bg-overlay)
- base.css: add light-theme overrides for nav dropdown menu (background, links, hover)
- ticket.css: add .lt-avatar-group and .lt-avatar--overflow styles for watcher display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: bootstrap.php rotates the CSRF token on every successful POST,
but most API endpoints called echo json_encode() directly instead of
apiRespond() — so the rotated token was never returned to the client.
The next POST from the same page sent the now-invalid old token → 403.
Refreshing the page loaded a fresh token, making it work once.
Fixes:
- assign_ticket.php, watch_ticket.php: switch to apiRespond()
- saved_filters.php, user_preferences.php: replace all echo json_encode
calls with apiRespond() (19 and 12 call sites respectively)
- base.js: both apiFetch() and _apiFetchAuth() now update window.CSRF_TOKEN
whenever a response includes a csrf_token field, keeping the client
permanently in sync with server-side rotations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ticket watchers:
- api/watch_ticket.php: GET (watch state) + POST (watch/unwatch toggle)
- index.php: route for /api/watch_ticket.php
- TicketView: WATCH/UNWATCH button with live state fetch and toggle
- NotificationHelper::notifyWatchers(): fetches watchers from DB, resolves
Matrix IDs via Synapse, fires notification to watchers + global list
- add_comment.php, update_ticket.php: call notifyWatchers on comment and
status-change events respectively
Fulltext search:
- TicketModel::hasFulltextIndex(): detects FULLTEXT index via information_schema
- getAllTickets(): uses MATCH...AGAINST when fulltext index exists, LIKE fallback
when not yet applied — zero-downtime rollout
Single-query pagination:
- getAllTickets() replaces separate COUNT + SELECT with COUNT(*) OVER() window
function — one round trip to DB per page load instead of two
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment pagination:
- CommentModel: add getCommentCount(), paginated getCommentsByTicketId()
with getThreadedCommentsPaged() for threading + LIMIT/OFFSET
- TicketController: load first 50 root comments + total count on page load
- api/get_comments.php: new AJAX endpoint for Load More (index.php routed)
- TicketView: Load More button + buildCommentEl() JS renderer for AJAX comments;
passes totalComments/commentOffset/isAdmin to window.ticketData
Matrix integration:
- NotificationHelper: add sendStatusChangeNotification(), sendCommentNotification(),
sendMentionNotification(), sendAssignmentNotification() alongside existing
sendTicketNotification(); internal fire() helper replaces duplicated cURL logic
- SynapseHelper: new helper that resolves SSO usernames → Matrix IDs by querying
Synapse Admin REST API directly (no caching, no stale data)
- config.php: add SYNAPSE_ADMIN_URL, SYNAPSE_ADMIN_TOKEN, MATRIX_NOTIFY_COMMENTS,
MATRIX_NOTIFY_ASSIGNMENTS config keys (all from .env)
- api/update_ticket.php: fire status-change notification after successful save
- api/add_comment.php: resolve @mentioned usernames via SynapseHelper and fire
mention notification; fire general comment notification when MATRIX_NOTIFY_COMMENTS=1
- api/assign_ticket.php: fire assignment notification (resolves assignee via Synapse)
when MATRIX_NOTIFY_ASSIGNMENTS=1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Optimistic locking:
- TicketView now includes updated_at in window.ticketData
- ticket.js saveTicket() sends expected_updated_at on every save so
the server can detect concurrent edits
- On conflict response, shows a clear toast: "ticket was modified by
someone else while you were editing — reload to see latest version"
- On success, syncs window.ticketData.updated_at from server response
so subsequent saves use the correct lock key
- update_ticket.php now returns updated_at in success response
Visibility audit log:
- updateVisibility() result is now checked; on success, logs a delta
entry to the audit trail with from/to visibility and groups so the
timeline shows who changed visibility and when
Full ticket export:
- export_tickets.php now accepts format=full with a single ticket_id
- Produces a JSON file containing ticket fields, flat comment list
(with author, timestamps, text), and the full audit timeline
- Access-controlled: respects canUserAccessTicket() before exporting
- EXPORT button added to ticket toolbar linking directly to the endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- POST /ticket_dependencies: verify user can access both the source
ticket and the target ticket before creating a dependency
- DELETE by ticket IDs: verify user can access source ticket; also
validate dependency_type against the allowed whitelist
- DELETE by dependency_id: look up dependency's ticket before deletion
and verify user can access it, preventing IDOR
- custom_fields.php: validate json_decode returns an array on POST/PUT;
add http_response_code(400) to all error responses
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- TicketModel::getAllTickets() now accepts optional $user param and applies
getVisibilityFilter() so non-admin users cannot see internal/confidential
tickets they lack access to from the dashboard listing
- DashboardController passes $GLOBALS['currentUser'] to getAllTickets()
- clone_ticket.php: move Content-Type header to top so all error paths send
correct JSON content type
- AuthMiddleware: filter group names from HTTP header to [a-z0-9_-] only,
preventing header injection via malformed group names
- add_comment.php: return HTTP 201 on success, 500 in catch block
- update_comment.php, delete_comment.php: return 500 in catch blocks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- upload_attachment.php: derive stored file extension from validated MIME type
instead of user-supplied filename, preventing executable extension attacks
(e.g. a PHP file renamed to evil.txt would now be stored as .txt)
- CustomFieldModel.php: fix bind_param type string in updateDefinition()
'sssssiiiii' (10 chars) → 'sssssiiii' (9 chars) to match 9 SQL placeholders
- RateLimitMiddleware.php: replace MD5 with SHA256 for rate limit file hashing
- user_preferences.php: add httponly, secure, samesite=Lax flags to ticketsPerPage
cookie to prevent XSS/CSRF cookie theft
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
P1-A: Fix CSP - add fonts.googleapis.com to style-src, fonts.gstatic.com to font-src
P1-B: CSRF token rotation - add rotateToken() to CsrfMiddleware; bootstrap.php rotates
after successful validation and stores in $GLOBALS['_new_csrf_token']; add
apiRespond() helper to append token to responses; lt.api interceptor in
layout_footer.php auto-updates window.CSRF_TOKEN from responses
P1-C: Styled 403/404 error views with TDS layout instead of raw text; index.php now
uses requireAdmin() helper eliminating 7 duplicated guard blocks (P3-D)
P2-A: Remove duplicate JS-generated keyboard help modal from keyboard-shortcuts.js;
'?' key now routes to static #lt-keys-help modal in footer
P2-B: Asset versioning driven by config ASSET_VERSION key; base.css and base.js get
?v= cache-busting in layout_header.php
P2-C: Add data-theme="dark" to <html> tag to prevent FOUC on light-mode users
P2-E: Escape status value in dashboard.js hover preview class attribute via lt.escHtml()
P2-F: Replace bespoke showLoadingOverlay() with lt-spinner / lt-loading-text from
base.css; add .lt-loading-overlay wrapper CSS to dashboard.css
P2-G: Add keyboard-shortcuts.js to all 7 admin views so J/K nav and ? help work
P3-A: APP_NAME, APP_SUBTITLE, APP_VERSION driven from config.php; layout header/footer
use config values instead of hardcoded strings
P3-G: Replace custom initTableSorting() with lt.sortTable.init() which manages aria-sort
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- bulk_operation.php: replace is_numeric() with strict int cast+equality to reject scientific notation
- AttachmentModel.php: fix bind_param type strings (s→i for integer ticket IDs)
- CommentModel.php: use strict !== comparison with (int) cast for user_id ownership checks
- ticket.js: replace all non-TDS class names (text-amber→lt-text-amber, btn→lt-btn variants, etc.)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- delete_attachment.php: add realpath() path traversal check before
unlink() — mirrors the defense-in-depth already in download_attachment.php;
also cast ticket_id to int when building the path
- manage_templates.php: add input validation to POST and PUT handlers:
required field checks, max length caps (name 100, title 255, desc 64KB),
allowlist validation for category/type, priority clamped to 1-5
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Before: entire ticket data was logged and shown in the activity tab.
After: compare old vs new values before saving; log only fields that
actually changed as { field: { from: '...', to: '...' } } pairs.
- TicketController.php: fetch old ticket before update, compute delta
- api/update_ticket.php: same fix for the API endpoint (currentTicket
already fetched for auth, reuse it for delta comparison)
- TicketView.php: render delta format as "Field: old → new" with color;
truncate long values (description) at 60 chars; keep legacy flat format
as fallback for older log entries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>