bulk_operations.performed_by and ticket_templates.created_by had no
ON DELETE clause (defaulting to RESTRICT), unlike every other
user-reference FK in the schema (tickets.*, ticket_attachments,
ticket_dependencies, recurring_tickets, api_keys), which all use
SET NULL. Deleting a user who ever ran a bulk operation or created a
template hard-failed at the DB level instead of nulling the
reference, breaking the pattern used everywhere else.
performed_by was NOT NULL, so it had to become nullable to support
SET NULL, matching how every other SET NULL column is defined.
- Fixed 000_baseline.sql for fresh installs.
- Added 003_fk_on_delete_set_null.sql for existing deployments.
Verified against a local MariaDB instance: reproduced the old RESTRICT
schema, ran the migration (twice, for idempotency), then confirmed
deleting a user with rows in both tables now nulls the references
instead of failing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
deleteOldLogs() ran a single unbounded DELETE. created_at is indexed
so row selection itself is cheap, but on a large qualifying set (first
run after enabling/changing AUDIT_LOG_RETENTION_DAYS, or after the
cron silently missed runs) an unbounded single-statement DELETE holds
row locks for the full duration — risking contention with the frequent
concurrent INSERTs the audit log receives from live traffic. Now
deletes in batches of 1000 (parameterized), looping until nothing
qualifies.
Verified against a local MariaDB instance with a batch size of 10
forcing multiple loop iterations: deleted exactly the stale rows,
left recent rows untouched, correct total count returned.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
api/get_users.php returned every user's user_id/username/display_name
to any authenticated session with no pagination or limit — needed for
mention/assignment typeahead, but a blanket enumeration a compromised
low-privilege session could scrape in one call. Added a LIMIT 500;
every caller already only uses this for typeahead/dropdown filtering,
never a literal full roster, so this doesn't change behavior for any
real deployment size while bounding the response.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
The last_activity subquery had no WHERE clause on the report's
date-range filter, so it always showed true all-time last activity
even when the page was filtered to e.g. "last 7 days" — inconsistent
with every other column on the same report. Added the same
DATE(created_at) BETWEEN ? AND ? clause used by the report's other
subqueries.
Verified against a local MariaDB instance: an out-of-range audit_log
row is correctly excluded from last_activity once the filter is
applied.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
setInterval(loadNotifications, 60000) ran unconditionally regardless
of tab visibility, and failures retried at the same fixed 60s cadence
forever. Now skips polling while document.hidden, resumes immediately
via visibilitychange when the tab regains focus, and backs off
exponentially (capped at 5 min) on repeated fetch failures, resetting
to the normal 60s cadence on the next success.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
toggleActive() flipped is_active without touching next_run_at. If a
schedule was disabled while next_run_at was still in the future, then
re-enabled after that date had passed, the next cron tick saw
next_run_at <= NOW() and fired immediately — surprising for an admin
expecting a re-enabled "daily" schedule to wait until its next natural
occurrence. Now recomputes next_run_at from the current time when
transitioning to active, matching what a fresh schedule creation would
produce; disabling is unchanged.
Verified against a local MariaDB instance: re-enabling a schedule
whose next_run_at was in 2020 recomputed it to tomorrow at the
scheduled time; disabling leaves next_run_at untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
README Dev Note #12 mandates utf8mb4_general_ci for new tables, but
these two tables were created with utf8mb4_unicode_ci in the baseline
schema — inconsistent with every other table, and a future join or
comparison against a general_ci column would need explicit COLLATE
casts or hit "Illegal mix of collations" errors.
- Fixed 000_baseline.sql so a fresh install matches the convention
directly.
- Added 002_fix_collation_consistency.sql for existing deployments.
MariaDB silently drops the inline CHECK (json_valid(...)) constraint
on saved_filters.filter_criteria when that column is MODIFYed (found
by actually running this against a local MariaDB instance), so the
migration explicitly re-adds it after the collation conversion.
Verified against a local MariaDB 10.11: baseline applies cleanly,
migration is idempotent (safe to run twice), and the json_valid CHECK
is still enforced afterward.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
syncUserFromAuthelia() did a plain check-then-insert with no
transaction, so two simultaneous first-visit requests for the same
brand-new user (e.g. two tabs opened right after SSO login) could
race: the second INSERT hits users.username's UNIQUE KEY, which
mysqli throws on (uncaught, PHP 8.1+ default report mode) rather than
returning false. Switched to INSERT ... ON DUPLICATE KEY UPDATE
followed by a re-fetch by username, so the losing request updates the
winner's row instead of throwing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
The JS side claimed to "mirror the PHP crc32 % 4 logic" but actually
implemented a different rolling hash (classic String.hashCode()-style),
so the same display name could get different avatar colors depending
on whether a comment was server-rendered or client-rendered (new
comment, reply, watcher avatars, "Load more" pagination).
Added a real CRC-32 (IEEE 802.3/zlib polynomial, UTF-8 byte sequence)
to ticket.js and switched all three JS call sites (avatarColorClass,
watcher avatars, and buildCommentEl in TicketView.php) to use it,
verified to produce identical output to PHP's crc32() including for
non-ASCII names.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
A user could set an end date before a start date, or priority_min >
priority_max, and the filter would be silently sent as an
unsatisfiable range with zero results and no explanation. Now swaps
min/max (and from/to) before building the query string when they're
in the wrong order.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
Two small dead-code cleanups from a dashboard.js state-management
audit:
- sortTable(table, column) had zero callers — actual table sorting is
wired through lt.sortTable.init() via initTableSorting().
- setViewMode() wrote localStorage['ticketViewMode'], but nothing ever
read it back; the real view-mode restoration on page load reads
lt_activeTab_<path>, written separately by lt.tabs in base.js.
Both looked load-bearing but weren't, risking a future edit assuming
otherwise.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
renderAttachments() decided whether to render an image thumbnail by
regex-matching the display filename extension, rather than the
finfo-verified mime_type the API already returns. A file whose real
type differs from its display name (e.g. a PDF a user named
photo.png) rendered a broken <img> instead of falling back to the
file-type icon.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
Zero callers anywhere in the codebase — superseded by claimForRun(),
which the cron script actually uses and which additionally guards
against the double-fire race between concurrent cron invocations that
this method lacked. Removing it so a future reuse doesn't silently
reintroduce that race.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
CreateTicketView.php was the one remaining spot using the native
confirm() dialog, violating README Dev Note #21. Split loadTemplate()
into a confirm check + applyTemplate(), routed through the project's
styled showConfirmModal(), matching every other destructive-action
confirmation in the app.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
gotoFilter() built a brand-new URLSearchParams containing only the
clicked chart segment's filter keys, discarding every other active
filter (search text, date range, saved-filter selection, etc.) on
navigation. Now merges the segment's filter into the current
location.search, same fix approach already applied to #22.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
quickAssign() wired lt.combobox.init() with an onSelect callback, but
combobox only supports the multi-select onChange(selected[]) contract
— onSelect is never invoked, so _quickAssignUserId stayed undefined no
matter what the user picked and Quick Assign always showed "Please
select a user from the list." Switched to lt.typeahead.init(), which
does support onSelect, matching the already-working Bulk Assign modal.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
The 'update' notification formatter unconditionally read
details['status']['from']/['to'], so any title/priority/description/
category/type/visibility-only edit fell through to '?' on both sides
and produced a broken "changed status on #123: ? → ?" title regardless
of what actually changed. Now it branches on the delta shape actually
present: the flat {field, from, to} shape used for visibility changes,
then each per-field {from, to} delta in priority order, falling back
to a generic "updated ticket" message only if none match.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
clearAllFilters() deleted the nonexistent date_from/date_to query
params. Every actual date filter (sidebar, Advanced Search, saved
filters, stat-card links) uses created_from/to, updated_from/to, and
closed_from/to, so clicking the button silently left any active date
range in place.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
add_comment.php computed a trimmed copy of comment_text only to check
for empty input, then passed the original untrimmed $data through to
CommentModel::addComment(), so any leading/trailing whitespace the
user typed (or pasted) was written to ticket_comments.comment_text as-is.
update_comment.php already trims before saving edits, so a comment
could pass through this endpoint once with untrimmed text (creation)
and be silently corrected the moment it was next edited — inconsistent
storage that, combined with the markdown parser's line-anchored regexes
(headings, tables, lists all match on ^), could make a markdown-enabled
comment mis-render after a reload depending on whether its first line
carried leading whitespace.
Also trims in the "Load more comments" pagination re-render path in
TicketView.php, matching the two on-load renderers in markdown.js so
all three code paths that call parseMarkdown() on stored comment text
treat leading whitespace consistently.
Closes #18
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
All three charts (priority donut, status donut, category bar) now navigate to
the same URL filters the stat cards already use, with a pointer cursor on
hover, a title hint, and "click to filter" in the tooltip.
The status each click applies is explicit rather than left to the default. With
no `status` param the controller falls back to the viewer's
default_status_filters preference, which can be anything, so the list would not
necessarily match what the chart counted. StatsModel builds by_priority and
by_category with `status != 'Closed'` while by_status spans every status, so
only the priority and category charts pin the open set; the status chart filters
on the clicked status alone (which is how clicking "Closed" works at all).
Verified two ways:
- 17/17 in headless chromium, driving the real chart script from this view with
the Chart constructor stubbed, asserting the exact query each click produces
and that a click hitting no segment navigates nowhere.
- Against the live database, every segment's count equals the number of tickets
its filter returns — 12/12 across all three charts — so the list you land on
matches the number you clicked.
A modal can be dismissed four ways: the ✕ button, Cancel, a backdrop click, or
Escape. base.js handles the last two globally (a document click handler and
registerKey('escape', closeAllModals)), so the status-change modal — which wired
only the two buttons — never learned it had been dismissed. The status dropdown
kept displaying the new status even though update_ticket.php was never called,
so the ticket looked closed with no comment until a reload showed it still open.
The same gap left every dynamically-inserted modal in the DOM when dismissed
that way, so the next open inserted a duplicate id that shadowed the live one.
- base.js closeModal now dispatches a bubbling lt:modalclose event (synced to
web_template as bbec859), and _statusCommentModal treats it as "no comment".
- ticket.js reverts the dropdown on any dismissal, guarded against the re-entry
its own lt.modal.close() would otherwise cause.
- dashboard.js gains openModalWithDismiss() so all seven dynamic modals plus the
generic prompt modal tear down however they are dismissed.
Verified in headless chromium against all four dismissal routes plus a
confirm-with-comment control: 22/22. Against the pre-fix files the same test
fails 6 assertions — backdrop and Escape leave the dropdown on "Closed *" with
an orphaned overlay — so it reproduces the reported behaviour exactly.
Setting the textarea's .value programmatically does not fire an 'input' event,
so updatePreview() never ran and the preview kept showing the just-posted
comment's rendered markdown underneath an empty composer.
(This change was already present in the working tree at the start of the
session; committing it on its own rather than folding it into an unrelated fix.)
.ticket-preview-popup used var(--lt-surface), which is not defined anywhere, so
the background always fell through to the hardcoded #0a0e14. In light mode that
left a near-black panel — and since the rule set no `color`, the inherited
near-black body text was effectively invisible on it. The border was hardcoded
neon green and the shadow a heavy rgba(0,0,0,0.5).
Now uses --bg-card / --text-primary / --accent-green-border / --shadow-color,
and .preview-id uses --accent-cyan instead of the undefined --lt-cyan.
base.css gains the two tokens the light theme was missing (--accent-green-border
and --shadow-color), synced from web_template 0d633bd.
Verified with computed styles in headless chromium: light body-text contrast on
the panel goes from invisible to 17.7:1, dark stays at 13.2:1, and the ID accent
clears 3:1 in both themes.
Two separate causes, both light-mode-only:
1. base.css `.lt-select` sets `color-scheme: dark` on the element itself, which
outranks the `color-scheme: light` the light theme sets on <html>, so the
native dropdown popup kept dark chrome. The option list is also hardcoded
#0d1117/#c9d1d9 with no light override. Fixed with light overrides for both
(synced from web_template, where the same fix landed as 378a8cd).
2. ticket.css coloured the status select with var(--lt-success), --lt-amber,
--lt-cyan and --lt-danger — none of which are defined anywhere in the
project, so all four always fell through to hardcoded neon fallbacks. Now
uses the --accent-* tokens, which carry the same hues and are redefined for
light mode. The selectors also lead with .lt-select: at two classes they lost
to base.css's `html[data-theme="light"] .lt-select` (0,2,1) and every status
was repainted near-black in light mode.
Verified with computed styles in headless chromium — all four statuses in both
themes (8/8), plus the popup colour-scheme and option colours.
Found while verifying #21 against the live schema: the model writes
'completed_with_errors' (21 chars) when a bulk operation finishes with
per-ticket failures, but bulk_operations.status was varchar(20), so the
write failed with "Data too long for column 'status'".
This was latent — bulk status changes previously forced every transition
through, so failed was always 0. Now that they honour the Workflow
Designer, a partially-skipped batch is a normal outcome and hits it.
- migrations/001 widens the column to varchar(32) (idempotent).
- The baseline is updated to match, for fresh installs.
- The bookkeeping UPDATE is wrapped in a try/catch: it runs after the
ticket changes are committed, so an instance deployed ahead of its
migrations must not turn a completed operation into an error response.
Verified against the live database with a disposable-ticket harness:
comment-required rejection changes nothing, undefined transitions are
refused per ticket with a reason, allowed transitions still work, mixed
batches apply the valid half, and an already-Closed ticket is a no-op.
Bulk status changes previously bypassed the workflow entirely — the model
carried an explicit "admin-only escape hatch" note — so bulk edit could
drive tickets through transitions the designer forbids and skip comments
the designer requires.
BulkOperationsModel now applies the same rules as the single-ticket path:
- Transitions absent from status_transitions are refused per ticket and
reported with a reason, instead of being forced through.
- requires_comment is checked up front across the whole selection, so a
batch is rejected before any ticket is mutated rather than half-applied.
- The reason is persisted as a comment on each ticket changed, matching
what a single-ticket close records.
- Tickets already in the target status are a no-op success, not a failure.
requires_admin needs no extra check: api/bulk_operation.php already gates
the endpoint on admin.
Client: both bulk modals now collect a reason, the close path gets a real
modal instead of a bare confirm, and per-ticket skip reasons surface in
the result toast instead of a bare failure count.
- README: Bearer API table (list/read/comment/status), scope explanation,
and the new endpoints in the API Endpoints table.
- /admin/api-keys API Usage section: scopes note + copy-paste cURL examples
for create, list/triage, read-one, comment, and close (uses APP_DOMAIN).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extends the Bearer-key API beyond create-only (all rate-limited, scope-
enforced, per-key-label attribution):
- GET /api/tickets_api.php: triage the queue (status/priority/host title
match + pagination) or read one ticket + its comments. read scope.
- POST /api/ticket_comment_api.php: post a comment as the key (user_name =
key name, linked to the key owner). read_write scope.
- POST /api/ticket_status_api.php: change/close status with workflow
validation + requires_comment; posts the close reason in the same call,
fires the Matrix status notification, invalidates stats. read_write scope.
Reuses TicketModel/CommentModel/WorkflowModel/NotificationHelper; a read
key cannot mutate. Reachability requires the reverse-proxy Authelia bypass
(handled separately).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Foundation for extending the Bearer API beyond create-only:
- api_keys gains a scope column (read | read_write); baseline schema updated
and the column applied to the live DB. Existing keys default to
read_write so the hwmon create key keeps working.
- ApiKeyModel: createKey() takes a validated scope; validateKey() always
surfaces scope (defaults read_write); getAllKeys() is paginated
({keys,total,page,perPage}, key_hash stripped).
- ApiKeyAuth: expose getKeyContext() (scope/key_name/created_by/api_key_id)
and requireScope() (403 on insufficient scope); existing return values
unchanged.
- create_ticket_api.php: require read_write scope (a read key can't create).
- Admin /admin/api-keys: scope selector on the create form, a scope column,
and pagination (revoked keys were stacking up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI has been red since the CSRF-drift changes landed a trailing space on the
'success' => false line in these two endpoints, which blocks the deploy job
(and therefore beta/prod). No logic change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stored markdown comments rendered fine in the live preview (parses the raw
textarea value) but broke after refresh: the server template emitted the
comment text on an indented line, so the on-load renderer parsed
element.textContent with ~20 spaces of leading indentation. Markdown treats
4+ leading spaces as a code block, so the first line (e.g. a heading or
table row) was mis-parsed and blocks got wrapped in <p>, producing invalid
HTML that broke the page layout.
- markdown.js: trim the text before parseMarkdown in both on-load renderers
so template indentation can't be parsed as a leading code block.
- TicketView.php: emit the comment text inline (no surrounding whitespace)
so the element's textContent is exactly the stored markdown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
- migrations/000_baseline.sql: full schema baseline captured from prod
(validated on a throwaway DB: 17 tables/17 FKs), so the schema is
reproducible for fresh installs / disaster recovery
- create_recurring_tickets cron: send the Matrix ticket-created
notification and invalidate the stats cache like the other create paths
- create_ticket_api.php + TicketController::create: invalidate the stats
cache on create/escalate/reopen so dashboard counts aren't stale
- scripts/cleanup_orphan_uploads.php: restored, made safe (24h mtime
grace, 9-digit-dir only, skips avatars/symlinks, matches the unique
filename column, --dry-run)
- cron/cleanup_audit_log.php: enforce the configured audit-log retention
(deleteOldLogs was implemented but never called)
- README: correct CSRF-rotation, hwmon dedup (no 24h window), SLA (no P3),
stats-cache callers, and the project structure/endpoint listing
- .env.example: document TRUSTED_PROXIES fail-open risk and .env quoting
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Consolidate the duplicated command palette to a single overlay + init in
the footer; fix New Ticket to route to /ticket/create (was a 404 /create);
keep the CSP nonce and all commands
- TicketController create(): trim title, require a non-empty description,
and honor the posted status (validated against the canonical list) instead
of silently discarding it
- UserActivityView: 'Active Users' counts only users active in the selected
range, not every registered user
- layout_footer/DashboardView: local esc() now escapes quotes so values used
in HTML attributes can't break out
- TicketView: comments tab badge shows the true total, not just page one
- layout_header: gate the 'View activity log' link behind the admin flag
- index.php: validate /admin/user-activity date params; anchor the legacy
/ticket.php route; align the audit action-type whitelist with the dropdown
- ApiKeysView: correct the external API sample to /create_ticket_api.php
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- base.js lt.api: resync window.CSRF_TOKEN from response bodies before
throwing on errors and attach err.data/err.status, so a desynced client
auto-recovers without a reload
- add lt.ticketStatus.submit: status changes that require a comment now
prompt, post the comment, and retry update_ticket with it; wired into
the ticket dropdown, dashboard quick-status, kanban drag-drop and the
1-4 keyboard shortcuts (bulk ops unchanged) — matches the new server
requires_comment enforcement
- base.js markdown.render: drop the unsafe marked/markdownit delegation;
always use the built-in XSS-safe renderer
- ticket.js: XHR upload sends the X-CSRF-Token header and resyncs the
token; use lt.escHtml instead of a re-inlined escape chain; @-mention
trigger requires a word boundary (no firing inside emails); idempotent,
anchor-safe highlightMentions
- base.js typeahead: discard out-of-order async results
- markdown.js: balanced table tbody/thead; ticket-ref linkification runs
after code extraction so #ids inside code aren't linked
- dashboard.js kanban: don't swallow the click after a drag
- keyboard-shortcuts.js: J/K skip hidden/skeleton rows; drop duplicate ?
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>
- Database.php: pin MySQL session time_zone to the configured named zone
(mysql.time_zone tables now loaded on the DB) with a fixed-offset
fallback, so NOW()/TIMESTAMP and PHP agree regardless of the DB server's
SYSTEM tz. Best-effort, never fatals the connection.
- NotificationHelper: redact comment-body previews for internal/
confidential tickets in sendCommentNotification and notifyWatchers so
they are not leaked to the shared Matrix notify list (new $visibility
param; callers wired in the API batch).
- config.php: die with a clear error if parse_ini_file fails instead of
silently falling back to insecure defaults (empty DB pass / proxies).
- CacheHelper: create cache dir 0700 and cache files 0600 so other local
users cannot read or poison security-relevant cached data.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- CustomFieldModel: assign ?? fallbacks to variables before bind_param
(by-reference args cannot be ?? expressions; fatal on PHP 8.2, custom
fields were uncreatable/uneditable)
- RecurringTicketModel::create: fix swapped bind type for schedule_type
(enum bound as int coerced 'daily' to 0, breaking the cron)
- TicketModel/CommentModel: bind varchar ticket_id as string not int so
the unique index is usable and leading-zero IDs match; ticket_watchers
(int column) left as integer
- TicketModel::deleteTicket: delete from custom_field_values (real table)
not the nonexistent ticket_custom_fields
- TicketModel search: honor literal '0'; never emit AGAINST('*') on
all-special-char input (fall back to LIKE)
- TicketModel::updateTicket: disambiguate not-found vs no-op vs genuine
optimistic-lock conflict on zero affected rows
- WorkflowModel: do not cache transitions/statuses on DB failure (a
transient error no longer blocks all status changes for the TTL)
- DependencyModel: filter linked tickets by visibility (new optional user
context params) to stop confidential metadata leaking via dependencies
- BulkOperationsModel: validate status/priority/assignee before mutating
- AuditLogModel: gate getClientIP forwarded headers on trusted proxies;
add missing action/entity types so audit-log filters work
- WorkflowModel: add transitionRequiresComment() accessor for enforcement
- CommentModel: stop leaking raw DB errors to clients (log instead)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>