Compare commits

..
Author SHA1 Message Date
jared 3664719148 Merge development into main: high-priority security/reliability batch (#27, #28, #30, #32)
Lint / PHP (phpcs PSR-12) (push) Successful in 29s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 25s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m44s
Lint / Deploy (push) Successful in 6s
- Add missing rate limiting to create_ticket_api.php (#27)
- Fix visibility-group matching disagreement between filter and access check (#28)
- Replace illusory transaction wrapping in migrate.php with statement-level resume (#30)
- Fix ticket_watchers.ticket_id type mismatch and missing FK (#32)
2026-09-08 21:33:33 -04:00
jaredandClaude Sonnet 5 d7940b1e31 Fix ticket_watchers.ticket_id type mismatch and missing FK (#32)
Lint / PHP (phpcs PSR-12) (push) Successful in 24s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 27s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m40s
Lint / Deploy (push) Successful in 2s
ticket_watchers.ticket_id was int(11) while every other satellite
table (ticket_comments, ticket_attachments, ticket_dependencies,
custom_field_values) uses varchar(9)/varchar(10) matching
tickets.ticket_id, and it had no FK constraint at all — unlike every
other satellite table — so orphaned watcher rows could never be
caught by referential integrity.

Changed the column to varchar(9) with an ON DELETE CASCADE FK to
tickets, in both 000_baseline.sql and a new idempotent
004_fix_ticket_watchers_type.sql (which also deletes any pre-existing
orphaned watcher rows before adding the constraint, since orphans
would otherwise make the ADD CONSTRAINT fail). Updated
watch_ticket.php, NotificationHelper::notifyWatchers(), and
notifications.php's audit-log JOIN to bind/compare ticket_id as a
string instead of casting to int, including replacing a fragile
CAST(entity_id AS UNSIGNED) with a direct string comparison.

Verified against real MariaDB: applied 004 against a simulated
pre-fix deployment with one valid and one orphaned watcher row —
the orphan is removed, the column converts losslessly, the FK is
added, and the migration is idempotent on re-run. Confirmed
ON DELETE CASCADE actually removes watchers when their ticket is
deleted, that inserting a watcher for a nonexistent ticket now fails
with a real FK violation, and exercised the updated watch/unwatch and
status-change-notification query paths end-to-end against the fixed
schema.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 21:28:42 -04:00
jaredandClaude Sonnet 5 fba251b85d Replace illusory transaction wrapping in migrate.php with statement-level resume (#30)
migrate.php wrapped each migration file's statements in
begin_transaction()/rollback(), but MySQL DDL statements cause an
implicit commit — so a rollback couldn't actually undo earlier DDL
already executed within the same file. A migration failing partway
left the DB altered but unrecorded, and the next run retried the
whole file from statement 1, hitting "already exists" errors not on
the safe-to-ignore allowlist and permanently wedging the runner.

Removed the transaction wrapper (it only gave false confidence) and
added a migration_progress table that records the index of the last
successfully-executed statement in each file. A re-run after a
partial failure now resumes right after the last success instead of
re-executing already-applied DDL. Verified against real MariaDB with
a 4-statement migration where statement 3 fails: run 1 correctly
applies statements 1-2 and records progress at index 1; after fixing
the bad statement, run 2 resumes at statement 3, completes, and
clears the progress marker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 21:28:32 -04:00
jaredandClaude Sonnet 5 fd777aa690 Fix visibility-group matching disagreement between filter and access check (#28)
getVisibilityFilter() (dashboard list/stats) matched via
FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', '')) — stripping
spaces from the column but not from the bound group name — while
canUserAccessTicket() (single-ticket access) did a plain trim with no
space-stripping at all. For a group name containing a space (e.g. "IT
Support"), a member could open an internal ticket directly by URL but
never see it in their dashboard list or stats counts.

Now strips spaces from the bound parameter too, matching the column-
side normalization, so both paths agree. Verified against real
MariaDB: a ticket visible via canUserAccessTicket() for a
space-containing group is now also matched by getVisibilityFilter()'s
SQL, a wrong-group user is denied by both, and the plain no-space case
is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 21:28:25 -04:00
jaredandClaude Sonnet 5 a9a39adcf8 Add missing rate limiting to create_ticket_api.php (#27)
Every other Bearer-key endpoint (ticket_status_api.php,
ticket_comment_api.php) calls RateLimitMiddleware::apply('api') before
opening a DB connection; create_ticket_api.php didn't, contradicting
README.md's claim that the whole Bearer API is rate-limited. A leaked
or guessed API key could hammer ticket creation unthrottled, each
insert also firing a Matrix webhook.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 21:28:18 -04:00
jared 23d94bfae7 Merge development into main: quick-win UX/perf batch (#76, #64, #99, #100)
Lint / PHP (phpcs PSR-12) (push) Successful in 37s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 35s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m24s
Lint / Deploy (push) Successful in 6s
- Consolidate dashboard clear-filters controls to one shared function (#76)
- Make SLA priority-alert banner update live on priority change (#64)
- Add HTTP Range/partial-content support to attachment downloads (#99)
- Paginate attachment listing (#100)
2026-09-08 21:18:14 -04:00
jaredandClaude Sonnet 5 e39b4f81ea Avoid insertAdjacentHTML flagged by semgrep in attachment pagination (#100)
Lint / PHP (phpcs PSR-12) (push) Successful in 46s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 33s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 4m0s
Lint / Deploy (push) Successful in 3s
The "load more attachments" append path used
grid.insertAdjacentHTML('beforeend', html), which the CI semgrep scan
flags as a blocking finding (detection of insertAdjacentHTML from a
non-constant string). The content was already fully escaped via
lt.escHtml() on every field, but switched to the same
temp-element + innerHTML + appendChild pattern used elsewhere to build
DOM from a generated HTML string, avoiding the flagged API without
changing behavior. Re-verified pagination append/remove behavior via
jsdom.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 21:05:43 -04:00
jaredandClaude Sonnet 5 3d5adbbfda Paginate attachment listing (#100)
Lint / PHP (phpcs PSR-12) (push) Successful in 36s
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP requirements (version + extensions) (push) Successful in 44s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Failing after 1m54s
Lint / Deploy (push) Successful in 2s
AttachmentModel::getAttachments() had no LIMIT/OFFSET, so a ticket
with hundreds of attachments loaded and rendered every one of them in
a single API response and DOM grid, unbounded.

Added optional limit/offset to getAttachments(), matching the pattern
already used by CommentModel::getCommentsByTicketId(). The GET handler
in upload_attachment.php now accepts limit/offset (default 40, capped
at 100) and returns total/has_more alongside the page of attachments.
ticket.js's loadAttachments()/renderAttachments() now fetch and append
pages, showing a "Load more attachments (N remaining)" control when
more are available. Verified against real MariaDB with 12 attachments
across 3 pages of 5: no duplicates or gaps across pages, and the
legacy unlimited call (getAttachments($ticketId) with no
limit/offset) still returns everything unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 20:57:22 -04:00
jaredandClaude Sonnet 5 b0765eb7f4 Add HTTP Range/partial-content support to attachment downloads (#99)
download_attachment.php always streamed the entire file regardless of
any Range request header, and never advertised Accept-Ranges. Large
video/PDF attachments couldn't be scrubbed in-browser, and an
interrupted download had to restart from byte 0.

Now parses a single-range "bytes=start-end" (including open-ended and
suffix forms) request header and responds with 206 Partial Content and
a Content-Range header, seeking the file handle to the requested
offset; out-of-range requests get 416 with Content-Range: bytes
*/<size>. Verified against a real file served over a local PHP dev
server with curl for exact-range, open-ended, suffix, no-Range, and
out-of-bounds cases, confirming byte-identical output for each.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 20:57:16 -04:00
jaredandClaude Sonnet 5 d84d8fae58 Make SLA priority-alert banner update live on priority change (#64)
The P1/P2 SLA breach banner and progress bar were rendered server-side
at page load and never touched again. Changing a ticket's priority in
edit mode (P1->P3 or P3->P1) left the banner in a stale state —
showing/counting for a priority that no longer applied — until the
page was reloaded.

Moved the banner's render/update/teardown logic into a reusable
renderSlaBanner() in ticket.js (verified via jsdom against real
DOM: creates the banner for P1/P2, removes it when priority drops
below P2 or the ticket is closed, and re-creates it including the
already-breached state when priority is raised into P1/P2 range).
The priority-change handler now calls it after a successful update,
and the initial page load calls it once instead of relying on
duplicated server-rendered markup + inline script.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 20:57:10 -04:00
jaredandClaude Sonnet 5 b6d3cc4e70 Consolidate dashboard clear-filters controls to one shared function (#76)
The sidebar's own Clear button cleared status/category/type/dates but
never search/priority/assigned_to, while the page-level "Clear All
Filters" button cleared a different subset. Neither control alone
reliably returned the dashboard to a fully unfiltered state. The
sidebar button now delegates to clearAllFilters() so both controls
always clear the same complete set of params.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 20:57:03 -04:00
jaredandClaude Sonnet 5 cef1689c05 Merge development into main: 15-issue triage + fix batch
Lint / PHP (phpcs PSR-12) (push) Successful in 23s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 24s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m55s
Lint / Deploy (push) Successful in 4s
Fixes 15 tracked issues from the tinker_tickets tracker, all verified
against a local MariaDB instance and/or jsdom/manual test harnesses
where applicable: #75, #84, #102, #29, #53, #90, #60, #79, #61, #31,
#96, #41, #89, #59, #52, #42, #66, #40, #106, #54, #92, #97, #51, #91,
#101, #63, #55, #65, #107.

Also fixes a real, previously-undetected outage in .env.example:
parse_ini_file() could not parse the file as shipped (fragile '#'
comment handling plus an unquoted LDAP_BIND_DN value), meaning the
documented setup step of `cp .env.example .env` would have broken
every fresh deployment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 15:39:36 -04:00
jaredandClaude Sonnet 5 3cca956ee7 Preserve native undo/redo in markdown toolbar buttons (#107)
Lint / PHP (phpcs PSR-12) (push) Successful in 23s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 29s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m32s
Lint / Deploy (push) Successful in 2s
insertMarkdownFormat, insertMarkdownText, toolbarList, toolbarHeading,
and toolbarQuote all set textarea.value = ... directly. Assigning
.value programmatically discards the browser's entire native undo
stack (vs. document.execCommand('insertText', ...), which preserves
it) — e.g. type a paragraph, click Bold, then Ctrl+Z undid the whole
paragraph instead of just the bold markup.

Added insertTextPreservingUndo(), which selects the exact range being
replaced and routes through execCommand('insertText', ...) — the same
mechanism real typing uses — falling back to the old direct assignment
(losing undo, matching prior behavior) only if execCommand is
unavailable or unsuccessful.

Verified with a jsdom harness that the fallback path (jsdom doesn't
implement execCommand, since native undo is a real-browser-only
feature untestable via jsdom) produces byte-identical resulting text
and cursor positions to the original implementation across all 5
toolbar functions, for both selected and cursor-only cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 14:21:39 -04:00
jaredandClaude Sonnet 5 818af137f3 Add touch-event fallback to lt.sortable for kanban drag-and-drop (#65)
lt.sortable only wired dragstart/dragend/dragover/drop — the native
HTML5 Drag-and-Drop API. iOS Safari doesn't implement HTML5 DnD on
arbitrary elements at all, and mobile Chrome's support is poor, so
kanban card status-drag was effectively unusable via touch, despite
README's "Touch-friendly controls" claim.

Added touchstart/touchmove/touchend/touchcancel handling that mirrors
the existing mouse-based behavior: a small movement threshold (8px)
distinguishes a tap/scroll from drag intent, the dragged card is
repositioned via fixed positioning to follow the finger (reparented to
document.body to avoid clipping by an overflow:hidden ancestor), and
elementFromPoint resolves the hover target for the same
placeholder-insertion logic dragover already uses, including
cross-column moves via the shared group check.

touchmove/touchend/touchcancel are registered on document rather than
the sortable list itself: since touch events keep targeting their
touchstart element for the whole gesture regardless of DOM mutations,
and the dragged item gets reparented to document.body mid-drag, a
listener on the original list would stop receiving bubbled events
once that reparenting happens.

lt.sortable lives in base.js, the shared web_template copy used by
other LotusGuild apps (per its own header comment) — this fix should
be contributed upstream too, not just kept local to this repo.

Verified with a jsdom harness (stubbing getBoundingClientRect and
elementFromPoint against known layouts) covering: sub-threshold
movement not starting a drag, same-column reorder, cross-column move
with correct final DOM parent and order, and touchcancel cleanly
resetting state. This caught a real bug during development — an
earlier version listened on the list element for touchmove/touchend,
which silently stopped receiving events after the drag-start
reparenting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 14:18:54 -04:00
jaredandClaude Sonnet 5 338bed7eb7 Add per-ticket attachment count/storage quota (#55)
api/upload_attachment.php enforced a per-file size cap but nothing
bounded the total number of attachments on a single ticket or their
cumulative size over time — an authenticated low-privilege user could
slowly fill the uploads/ disk by attaching many files across tickets,
bounded only by the general rate limiter (which throttles request
rate, not storage volume).

Added MAX_ATTACHMENTS_PER_TICKET (50) and
MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET (100MB) config defaults, enforced
before move_uploaded_file() using AttachmentModel::getAttachmentCount()
and getTotalSizeForTicket() — both already existed in the model with
zero callers, apparently added for exactly this purpose but never
wired in.

Verified against a local MariaDB instance: with 3 existing 1MB
attachments and a 3-attachment cap, the count check correctly rejects
a 4th; with a 5MB total cap, a 2.5MB upload that would push the ticket
over the limit is correctly rejected while a small one that fits is
not.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 14:14:00 -04:00
jaredandClaude Sonnet 5 67d3c13bb6 Fix .env.example: missing Matrix vars, plus a real parse_ini_file outage (#63)
Primary fix (#63): added the 5 env vars config.php reads and README
documents but .env.example never listed: MATRIX_DOMAIN,
SYNAPSE_ADMIN_URL, SYNAPSE_ADMIN_TOKEN, MATRIX_NOTIFY_COMMENTS, and
MATRIX_NOTIFY_ASSIGNMENTS. A deployer following only .env.example had
no indication these existed, silently missing watcher Matrix DMs and
comment/assignment notifications.

While verifying the fix by actually running .env.example through
parse_ini_file() (what config.php calls), found this file could not
be parsed at all — a real, currently-live outage for anyone following
its own first-line instruction ("Copy this file to .env and fill in
your values"):

1. PHP's ini parser treats "#" comments as fragile: punctuation like
   parentheses or quotes inside a "#" comment can throw a syntax error
   even though the line is meant to be inert. The file's header
   comment itself (and 15+ other comment lines) tripped this. Switched
   every comment to ";", which parse_ini_file treats as a true inert
   comment regardless of content — verified with isolated repros of
   both prefixes under all three INI_SCANNER_* modes.
2. LDAP_BIND_DN's example value contained unquoted "=" and commas,
   violating the file's own documented quoting rule and causing a
   second, independent parse failure. Quoted it (and the two other
   comma-bearing LDAP DN values) to match the rule.

config.php has zero fallback for a parse failure — it die()s
immediately — so either bug alone would have taken down every fresh
deployment that didn't hand-edit the example file's comments first.

Verified end-to-end: copied .env.example to a real .env file
unmodified and ran it through config.php's exact parse_ini_file +
quote-stripping logic; it now parses cleanly with all 23 keys
(including the 5 new ones) and LDAP_BIND_DN resolves to the correct
unquoted DN string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 14:12:04 -04:00
jaredandClaude Sonnet 5 e4c240009d Dashboard cleanup: chart empty-state message, dead click-handler (#101)
Two small findings from the same dashboard audit pass:

1. Charts rendered a bare empty frame with no message when the
   filtered dataset was empty (fresh install, or a non-admin's
   visibility-filtered ticket set happening to be zero). makeDonut/
   makeBar now show a "No data for current filters" message in the
   chart's place instead of silently doing nothing.

2. Stat cards had two independent, redundant click-handler
   implementations. lt.statsFilter.init() (base.js, shared web_template
   code) read each card's data-filter-key/data-filter-val attributes
   and called window.lt_onStatFilter(key, val) on click — but that
   global is never defined anywhere in this app, so it only toggled a
   cosmetic .active class with no functional effect. The actual
   navigation logic is the separate handler at ~line 1282 that ignores
   those attributes entirely. Both fired on the same click with no
   visible symptom, but the markup looked load-bearing and wasn't — a
   trap for a future edit that touches one implementation assuming
   it's the only one. Removed the dead lt.statsFilter.init() call and
   the now-unused data-filter-key/data-filter-val attributes from this
   app's DashboardView.php (left the shared lt.statsFilter module in
   base.js itself untouched, since other LotusGuild apps consuming the
   same shared template file may define their own lt_onStatFilter).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 12:27:26 -04:00
jaredandClaude Sonnet 5 6553c0227d Fix dogpile cache-overwrite race in CacheHelper::remember() (#91)
remember() had no protection against a slow cache-miss recomputation
overwriting a fresher write. If Request A started computing stats just
before a ticket mutation + invalidateCache(), and Request B started
just after (correctly computing fresh, post-mutation data), A could
finish (using stale pre-mutation data) after B and overwrite B's fresh
cache entry — extending staleness by up to another full TTL.

Added a per-prefix invalidation epoch: delete() bumps it, and
remember() snapshots it before running the callback and only writes
if the epoch hasn't changed since — otherwise a newer invalidation
happened mid-computation and the result being written is already
stale, so it's dropped (the caller still gets its own result; only the
cache write is skipped).

Verified with two real concurrent PHP processes racing against the
same cache key (a slow "Request A" callback vs. a fast "Request B"
that invalidates then recomputes): the cache ends up holding B's fresh
value, not A's late stale overwrite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 12:25:04 -04:00
jaredandClaude Sonnet 5 4fade1a9d3 Reject the semantic inverse of an existing ticket dependency (#51)
addDependency()'s "already exists" check only matched the exact
(ticket_id, depends_on_id, dependency_type) tuple. A user could add
"A blocks B" from ticket A's page, then separately add "B blocked_by
A" from ticket B's page — wouldCreateCycle() correctly found no cycle
(both normalize to the same precedence edge), so the insert was
allowed, creating two DB rows describing one real relationship (shown
twice on ticket B's page: once under Dependencies, once under
Dependents).

Added an inverse-relationship check before the insert: blocks/
blocked_by are inverses of each other, relates_to is its own inverse
(symmetric). duplicates has no defined inverse type in the schema, so
both directions remain independently insertable, which is correct —
"A duplicates B" and "B duplicates A" are distinct claims.

Verified against a local MariaDB instance: the exact repro from the
issue (A blocks B, then B blocked_by A) is now rejected, relates_to's
symmetric case is rejected in both directions, and duplicates in
either direction is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 12:23:04 -04:00
jaredandClaude Sonnet 5 3f1e06479d Strip EXIF/GPS metadata from image uploads (#97)
api/upload_attachment.php did a raw move_uploaded_file() with zero
image processing. A photo attached from a phone retained embedded
EXIF, including GPS coordinates, and download_attachment.php streams
the file byte-for-byte back to any user with ticket visibility — for
an infrastructure company, this could leak a data center or office's
precise physical location through a routine ticket photo, especially
on Confidential-visibility tickets whose whole point is restricting
exactly this kind of detail.

Added stripImageMetadata(): decodes and re-encodes JPEG/PNG/GIF/WebP
uploads via GD, which drops EXIF chunks that aren't part of the pixel
data. Best-effort — leaves the file untouched on any failure (corrupt
image, unsupported format, GD unavailable, or an oversized decoded
pixel count guarding against a decompression-bomb-style crafted image)
rather than blocking the upload.

Verified with a real GPS-tagged JPEG (generated via piexif) and a
GD/PHP harness: GPS EXIF is gone after stripping, the image stays
valid and correctly sized, PNG alpha transparency is preserved, and
corrupt files / non-image MIME types are left byte-for-byte unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 12:22:02 -04:00
jaredandClaude Sonnet 5 caeb9269d9 README: add missing StatsModel::invalidateCache() caller (#92)
Lint / PHP (phpcs PSR-12) (push) Successful in 38s
Lint / JS (eslint) (push) Successful in 15s
Lint / PHP requirements (version + extensions) (push) Successful in 56s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m25s
Lint / Deploy (push) Successful in 5s
Dev Note #24 listed 7 callers; api/ticket_status_api.php (the Bearer
API's status-change endpoint) also correctly calls invalidateCache()
but wasn't in the list. Behavior was already correct — this is a pure
documentation completeness fix so the caller list stays an accurate
reference for future maintainers deciding whether a new mutating path
needs the same call.

Verified via grep -rl "invalidateCache" that these 8 files (plus
StatsModel.php itself, and an unrelated same-named method on
UserModel) are the complete set of real callers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 11:59:23 -04:00
jaredandClaude Sonnet 5 70ef42c311 Delete dead helpers/OutputHelper.php (#54)
Zero callers anywhere in the app — confirmed via grep for
"OutputHelper::" across the whole codebase. Every view actually calls
htmlspecialchars() directly instead, which a prior audit confirmed is
done consistently, so escaping was never actually at risk. This was
just a misleading, unused class that README.md's file reference
implied was part of the app's active XSS-prevention story. Removed the
file and its README Project Structure entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 11:58:58 -04:00
jaredandClaude Sonnet 5 1e972fe7dc Add memory_limit/max_execution_time sanity checks (#106)
config/requirements.php only checked PHP version and 6 extensions. A
deployment on a host with a low default memory_limit (e.g. shared-
hosting-style 128M) passed the startup requirements check cleanly and
only surfaced as a mysterious failure under real load — a large CSV
export, an oversized dashboard query on a big install.

Added min_memory_limit_mb (256) and min_max_execution_time (30s)
thresholds to config/requirements.php, checked as warnings (not hard
failures, since a low limit doesn't break every request) in both
scripts/check_requirements.php (CI) and api/health.php (production
monitoring). -1/0 (unlimited) always passes.

Verified the ini-size parsing and warning logic directly with
low/high/unlimited memory_limit and max_execution_time values.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 11:58:20 -04:00
jaredandClaude Sonnet 5 700048337f Fix inconsistent FK ON DELETE behavior on bulk_operations/ticket_templates (#40)
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
2026-09-08 11:56:33 -04:00
jaredandClaude Sonnet 5 3221ccfd29 Batch the audit log retention DELETE to bound lock hold time (#66)
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
2026-09-08 11:53:42 -04:00
jaredandClaude Sonnet 5 0d6b08f5d2 Cap get_users.php result set as defense-in-depth (#42)
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
2026-09-08 11:53:36 -04:00
jaredandClaude Sonnet 5 92aea89b74 User Activity report: filter 'Last Activity' by the selected date range (#52)
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
2026-09-08 11:53:31 -04:00
jaredandClaude Sonnet 5 f59b3d529b Notification bell: pause polling when tab hidden, backoff on failure (#59)
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
2026-09-08 11:53:25 -04:00
jaredandClaude Sonnet 5 c892d9dcc8 Recompute next_run_at when re-enabling a paused recurring schedule (#89)
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
2026-09-08 11:45:11 -04:00
jaredandClaude Sonnet 5 5e8af39563 Fix collation inconsistency on saved_filters/ticket_attachments (#41)
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
2026-09-08 11:43:53 -04:00
jaredandClaude Sonnet 5 7c1c1b61cc Fix race in first-time login user creation (#96)
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
2026-09-08 11:41:30 -04:00
jaredandClaude Sonnet 5 6eefeafcbf Fix avatar color drift between PHP and JS (#31)
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
2026-09-08 11:40:43 -04:00
jaredandClaude Sonnet 5 e0c7399998 Advanced Search: swap inverted date/priority ranges instead of submitting them (#61)
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
2026-09-08 10:54:56 -04:00
jaredandClaude Sonnet 5 4d0dc2c2ce Remove dead sortTable() and write-only ticketViewMode key (#79)
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
2026-09-08 10:54:51 -04:00
jaredandClaude Sonnet 5 e9494dd4b3 Use server-verified mime_type for attachment thumbnail detection (#60)
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
2026-09-08 10:54:45 -04:00
jaredandClaude Sonnet 5 33de91cc86 Delete dead RecurringTicketModel::updateAfterRun() (#90)
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
2026-09-08 10:44:19 -04:00
jaredandClaude Sonnet 5 f7872b0980 Use showConfirmModal() instead of browser confirm() for template overwrite (#53)
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
2026-09-08 10:44:15 -04:00
jaredandClaude Sonnet 5 2bda603647 Chart click-to-filter now merges into the current query string (#29)
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
2026-09-08 10:44:10 -04:00
jaredandClaude Sonnet 5 1ab4d01a3a Fix broken Quick Assign dropdown (#102)
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
2026-09-08 10:41:47 -04:00
jaredandClaude Sonnet 5 6183bcd421 Notification titles: handle non-status ticket edits (#84)
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
2026-09-08 10:41:29 -04:00
jaredandClaude Sonnet 5 1617dc5442 Fix 'Clear All Filters' to clear the real date-range params (#75)
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
2026-09-08 10:41:24 -04:00
jaredandClaude Sonnet 5 6e0863449f Trim comment text before persisting, not just for validation
Security / PHP Security (semgrep) (push) Successful in 2m6s
Lint / Deploy (push) Successful in 3s
Lint / PHP (phpcs PSR-12) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 11s
Lint / PHP requirements (version + extensions) (push) Successful in 29s
Lint / Notify on failure (push) Skipped
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>
2026-08-31 21:03:00 -04:00
jared 1fb984e352 Merge #22 chart click-to-filter into main
Lint / JS (eslint) (push) Successful in 19s
Lint / PHP requirements (version + extensions) (push) Successful in 56s
Lint / PHP (phpcs PSR-12) (push) Successful in 30s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m3s
Lint / Deploy (push) Successful in 19s
2026-08-07 23:15:59 -04:00
jared ce0ea66994 Charts: click a segment to filter the dashboard (#22)
Lint / PHP (phpcs PSR-12) (push) Successful in 31s
Lint / JS (eslint) (push) Successful in 21s
Lint / PHP requirements (version + extensions) (push) Successful in 1m7s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Successful in 3s
Security / PHP Security (semgrep) (push) Successful in 2m0s
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.
2026-08-07 23:15:51 -04:00
37 changed files with 1185 additions and 581 deletions
+56 -37
View File
@@ -1,60 +1,79 @@
# Tinker Tickets Environment Configuration ; Tinker Tickets Environment Configuration
# Copy this file to .env and fill in your values ; Copy this file to .env and fill in your values
# ;
# NOTE: This file is parsed with parse_ini_file(). Any value containing special ; NOTE: This file is parsed with PHP's parse_ini_file. Any value containing
# characters (#, ;, =, quotes, spaces, etc.) MUST be wrapped in double quotes, ; special characters -- #, ;, =, quotes, spaces, etc. -- MUST be wrapped in
# e.g. DB_PASS="p@ss;word#1". The application now fails loudly (dies with a clear ; double quotes, e.g. DB_PASS="p@ss;word#1". The application now fails loudly
# error) if the .env file cannot be parsed, so an unquoted special character will ; -- dies with a clear error -- if the .env file cannot be parsed, so an
# take the whole app down rather than silently using a wrong value. ; unquoted special character will take the whole app down rather than
; silently using a wrong value.
;
; Comments in this file use ";" rather than "#": PHP's ini parser treats "#"
; comments as fragile -- punctuation like parentheses or quotes inside a "#"
; comment can produce a syntax error even though the line is meant to be
; inert, silently breaking every value below it. ";" comments don't have this
; problem, so keep using ";" for any comment added to this file.
# Database Configuration ; Database Configuration
DB_HOST=10.10.10.50 DB_HOST=10.10.10.50
DB_USER=tinkertickets DB_USER=tinkertickets
DB_PASS=your_password_here DB_PASS=your_password_here
DB_NAME=ticketing_system DB_NAME=ticketing_system
# Matrix Webhook (optional - for notifications via matrix-hookshot) ; Matrix Webhook (optional - for notifications via matrix-hookshot)
# Set to your hookshot generic webhook URL, e.g.: ; Set to your hookshot generic webhook URL, e.g.:
# https://matrix.lotusguild.org/webhook/<uuid> ; https://matrix.lotusguild.org/webhook/uuid-goes-here
MATRIX_WEBHOOK_URL= MATRIX_WEBHOOK_URL=
# Matrix users to @mention on every new ticket (comma-separated Matrix user IDs) ; Matrix users to @mention on every new ticket (comma-separated Matrix user IDs)
# e.g. @jared:matrix.lotusguild.org,@alice:matrix.lotusguild.org ; e.g. @jared:matrix.lotusguild.org,@alice:matrix.lotusguild.org
MATRIX_NOTIFY_USERS= MATRIX_NOTIFY_USERS=
# Application Domain (required for Matrix webhook ticket links) ; Matrix homeserver domain (used to build Matrix user IDs from LLDAP usernames)
# Set this to your public domain (e.g., t.lotusguild.org) MATRIX_DOMAIN=
; Synapse internal URL and admin token (used to resolve usernames -> Matrix IDs
; for watcher DMs)
SYNAPSE_ADMIN_URL=
SYNAPSE_ADMIN_TOKEN=
; Optional: send a Matrix notification on comments and/or assignments (0/1)
MATRIX_NOTIFY_COMMENTS=0
MATRIX_NOTIFY_ASSIGNMENTS=0
; Application Domain (required for Matrix webhook ticket links)
; Set this to your public domain, e.g. t.lotusguild.org
APP_DOMAIN= APP_DOMAIN=
# Allowed Hosts for HTTP_HOST validation (comma-separated) ; Allowed Hosts for HTTP_HOST validation (comma-separated)
# Include all domains that can access this application ; Include all domains that can access this application
ALLOWED_HOSTS=localhost,127.0.0.1 ALLOWED_HOSTS=localhost,127.0.0.1
# Trusted reverse proxy IP(s), comma-separated (e.g. the Authelia/nginx proxy). ; Trusted reverse proxy IPs, comma-separated -- e.g. the Authelia/nginx proxy.
# Set this to the IP address(es) of your reverse proxy. Authelia forward-auth ; Set this to the IP address(es) of your reverse proxy. Authelia forward-auth
# headers (Remote-User / Remote-Groups) and forwarded client IPs are only ; headers (Remote-User / Remote-Groups) and forwarded client IPs are only
# trusted when REMOTE_ADDR is in this list. ; trusted when REMOTE_ADDR is in this list.
# ;
# Leaving this EMPTY disables reverse-proxy verification entirely: the app then ; Leaving this EMPTY disables reverse-proxy verification entirely: the app then
# trusts Remote-User / Remote-Groups headers from ANY source. That is unsafe if ; trusts Remote-User / Remote-Groups headers from ANY source. That is unsafe if
# the PHP backend is reachable directly (bypassing the proxy), because a client ; the PHP backend is reachable directly (bypassing the proxy), because a client
# can then spoof those headers and log in as an admin. Only leave it empty when ; can then spoof those headers and log in as an admin. Only leave it empty when
# network topology guarantees PHP is reachable solely via the trusted proxy. ; network topology guarantees PHP is reachable solely via the trusted proxy.
# ;
# Exact IP match only (no CIDR). Example (single proxy): TRUSTED_PROXIES=10.10.10.27 ; Exact IP match only (no CIDR). Example (single proxy): TRUSTED_PROXIES=10.10.10.27
# Example (multiple): TRUSTED_PROXIES=10.10.10.27,10.10.10.28 ; Example (multiple): TRUSTED_PROXIES=10.10.10.27,10.10.10.28
TRUSTED_PROXIES= TRUSTED_PROXIES=
# Timezone (default: America/New_York) ; Timezone (default: America/New_York)
TIMEZONE=America/New_York TIMEZONE=America/New_York
# LDAP / lldap (for user avatar lookups) ; LDAP / lldap (for user avatar lookups)
LDAP_ENABLED=true LDAP_ENABLED=true
LDAP_HOST=10.10.10.39 LDAP_HOST=10.10.10.39
LDAP_PORT=3890 LDAP_PORT=3890
LDAP_BIND_DN=uid=tinker-tickets,ou=people,dc=example,dc=com LDAP_BIND_DN="uid=tinker-tickets,ou=people,dc=example,dc=com"
LDAP_BIND_PW= LDAP_BIND_PW=
LDAP_BASE_DN=dc=example,dc=com LDAP_BASE_DN="dc=example,dc=com"
LDAP_USER_BASE=ou=people,dc=example,dc=com LDAP_USER_BASE="ou=people,dc=example,dc=com"
# How long to cache avatar images locally (seconds, default 3600) ; How long to cache avatar images locally (seconds, default 3600)
AVATAR_CACHE_TTL=3600 AVATAR_CACHE_TTL=3600
+1 -2
View File
@@ -362,7 +362,6 @@ tinker_tickets/
│ ├── Database.php # Centralized mysqli connection │ ├── Database.php # Centralized mysqli connection
│ ├── ErrorHandler.php # Global error/exception handler │ ├── ErrorHandler.php # Global error/exception handler
│ ├── NotificationHelper.php # Matrix hookshot webhook events │ ├── NotificationHelper.php # Matrix hookshot webhook events
│ ├── OutputHelper.php # Safe HTML output helpers
│ ├── ResponseHelper.php # JSON API response helpers │ ├── ResponseHelper.php # JSON API response helpers
│ ├── SynapseHelper.php # Resolves usernames → Matrix IDs via Synapse admin API │ ├── SynapseHelper.php # Resolves usernames → Matrix IDs via Synapse admin API
│ └── UrlHelper.php # Canonical ticket URLs using APP_DOMAIN │ └── UrlHelper.php # Canonical ticket URLs using APP_DOMAIN
@@ -556,7 +555,7 @@ Key conventions and gotchas for working with this codebase:
21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`. 21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`.
22. **`utils.js` on all pages**: `utils.js` is loaded by all views (including admin). It provides `escapeHtml()`, `getTicketIdFromUrl()`, and `showConfirmModal()`. 22. **`utils.js` on all pages**: `utils.js` is loaded by all views (including admin). It provides `escapeHtml()`, `getTicketIdFromUrl()`, and `showConfirmModal()`.
23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`. 23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`.
24. **Stats cache**: `StatsModel` caches stats for 60 s. Any path that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after the change. Callers: `TicketController::create` (manual create), `create_ticket_api.php` (external API create/escalate/reopen), `cron/create_recurring_tickets.php`, `bulk_operation`, `assign_ticket`, `update_ticket`, and `clone_ticket`. 24. **Stats cache**: `StatsModel` caches stats for 60 s. Any path that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after the change. Callers: `TicketController::create` (manual create), `create_ticket_api.php` (external API create/escalate/reopen), `cron/create_recurring_tickets.php`, `bulk_operation`, `assign_ticket`, `update_ticket`, `clone_ticket`, and `ticket_status_api.php` (Bearer API status-change endpoint).
25. **External API (`create_ticket_api.php`)**: Uses `ApiKeyAuth` (Bearer token), not session auth. Served directly by the web server from the document root — not through the index.php router. Includes deduplication logic (SHA-256 hash, no time window) that updates/escalates an existing open duplicate or reopens a closed one rather than creating a new ticket. 25. **External API (`create_ticket_api.php`)**: Uses `ApiKeyAuth` (Bearer token), not session auth. Served directly by the web server from the document root — not through the index.php router. Includes deduplication logic (SHA-256 hash, no time window) that updates/escalates an existing open duplicate or reopens a closed one rather than creating a new ticket.
## File Reference ## File Reference
+5
View File
@@ -99,6 +99,11 @@ try {
exit; exit;
} }
// Persist the trimmed text (not the raw client value) — matches update_comment.php
// and keeps stored comment_text free of leading whitespace that could shift a
// markdown-enabled comment's first line out of column 0 on reload.
$data['comment_text'] = $commentTextRaw;
// Never trust a client-supplied display name — always attribute the comment to // Never trust a client-supplied display name — always attribute the comment to
// the authenticated session user. // the authenticated session user.
$data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User'; $data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
+50 -3
View File
@@ -102,12 +102,50 @@ try {
// 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']);
$fileSize = filesize($filePath);
// Parse a single-range "Range: bytes=start-end" request header (RFC 7233).
// Multi-range requests aren't supported; they fall through to a full 200 response.
$rangeStart = 0;
$rangeEnd = $fileSize - 1;
$isRangeRequest = false;
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/^bytes=(\d*)-(\d*)$/', trim($_SERVER['HTTP_RANGE']), $m)) {
if ($m[1] === '' && $m[2] === '') {
// Malformed ("bytes=-") — ignore and serve the full file.
} elseif ($m[1] === '') {
// Suffix range: last N bytes
$suffixLength = (int)$m[2];
$rangeStart = max(0, $fileSize - $suffixLength);
$rangeEnd = $fileSize - 1;
$isRangeRequest = true;
} else {
$rangeStart = (int)$m[1];
$rangeEnd = ($m[2] === '') ? $fileSize - 1 : min((int)$m[2], $fileSize - 1);
$isRangeRequest = true;
}
if ($isRangeRequest && ($rangeStart > $rangeEnd || $rangeStart >= $fileSize)) {
http_response_code(416);
header('Content-Range: bytes */' . $fileSize);
exit;
}
}
$rangeLength = $rangeEnd - $rangeStart + 1;
header('Accept-Ranges: bytes');
header('Content-Type: ' . $attachment['mime_type']); header('Content-Type: ' . $attachment['mime_type']);
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"'); header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
header('Content-Length: ' . $attachment['file_size']);
header('Cache-Control: private, max-age=3600'); header('Cache-Control: private, max-age=3600');
header('X-Content-Type-Options: nosniff'); header('X-Content-Type-Options: nosniff');
if ($isRangeRequest) {
http_response_code(206);
header('Content-Range: bytes ' . $rangeStart . '-' . $rangeEnd . '/' . $fileSize);
}
header('Content-Length: ' . $rangeLength);
// Prevent PHP from timing out on large files // Prevent PHP from timing out on large files
set_time_limit(0); set_time_limit(0);
@@ -125,9 +163,18 @@ try {
exit; exit;
} }
while (!feof($handle)) { fseek($handle, $rangeStart);
echo fread($handle, 8192); $remaining = $rangeLength;
$chunkSize = 8192;
while ($remaining > 0 && !feof($handle)) {
$read = ($remaining < $chunkSize) ? $remaining : $chunkSize;
$data = fread($handle, $read);
if ($data === false) {
break;
}
echo $data;
flush(); flush();
$remaining -= strlen($data);
} }
fclose($handle); fclose($handle);
+4 -2
View File
@@ -8,8 +8,10 @@
require_once __DIR__ . '/bootstrap.php'; require_once __DIR__ . '/bootstrap.php';
try { try {
// Get all users for mentions/assignment // Get all users for mentions/assignment. Capped as defense-in-depth against
$result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username"); // a single call scraping an unbounded user list — every caller only needs
// this for typeahead/dropdown filtering, never a literal full roster.
$result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username LIMIT 500");
if (!$result) { if (!$result) {
throw new Exception("Failed to query users"); throw new Exception("Failed to query users");
+33
View File
@@ -129,6 +129,39 @@ if (version_compare(PHP_VERSION, $requirements['min_php_version'], '>=')) {
$healthy = false; $healthy = false;
} }
// Check 7: memory_limit / max_execution_time sanity (warnings, not fatal — a
// low default doesn't fail requests until something large actually runs, so
// surface it here rather than waiting for a mysterious failure under load).
$memLimitIni = ini_get('memory_limit');
$memLimitUnit = strtolower(substr(trim($memLimitIni), -1));
$memLimitBytes = $memLimitIni === '-1'
? -1
: (int)$memLimitIni * match ($memLimitUnit) {
'g' => 1024 * 1024 * 1024,
'm' => 1024 * 1024,
'k' => 1024,
default => 1,
};
$minMemBytes = $requirements['min_memory_limit_mb'] * 1024 * 1024;
if ($memLimitBytes === -1 || $memLimitBytes >= $minMemBytes) {
$checks['memory_limit'] = ['status' => 'ok', 'message' => $memLimitIni];
} else {
$checks['memory_limit'] = [
'status' => 'warning',
'message' => sprintf('%s is below the recommended minimum %dM', $memLimitIni, $requirements['min_memory_limit_mb'])
];
}
$maxExecTime = (int)ini_get('max_execution_time');
if ($maxExecTime === 0 || $maxExecTime >= $requirements['min_max_execution_time']) {
$checks['max_execution_time'] = ['status' => 'ok', 'message' => (string)$maxExecTime];
} else {
$checks['max_execution_time'] = [
'status' => 'warning',
'message' => sprintf('%ds is below the recommended minimum %ds', $maxExecTime, $requirements['min_max_execution_time'])
];
}
// Calculate response time // Calculate response time
$responseTime = round((microtime(true) - $startTime) * 1000, 2); $responseTime = round((microtime(true) - $startTime) * 1000, 2);
+16 -5
View File
@@ -138,7 +138,7 @@ $statusSql = "SELECT DISTINCT
COALESCE(u.display_name, u.username, 'System') AS actor_name COALESCE(u.display_name, u.username, 'System') AS actor_name
FROM audit_log al FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id LEFT JOIN users u ON al.user_id = u.user_id
INNER JOIN ticket_watchers tw ON tw.ticket_id = CAST(al.entity_id AS UNSIGNED) AND tw.user_id = ? INNER JOIN ticket_watchers tw ON tw.ticket_id = al.entity_id AND tw.user_id = ?
WHERE al.action_type = 'update' WHERE al.action_type = 'update'
AND al.entity_type = 'ticket' AND al.entity_type = 'ticket'
AND al.user_id != ? AND al.user_id != ?
@@ -225,10 +225,21 @@ foreach ($all as $row) {
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}", 'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
'mention' => "{$row['actor_name']} mentioned you on ticket #{$ticketId}", 'mention' => "{$row['actor_name']} mentioned you on ticket #{$ticketId}",
'update' => (function () use ($row, $details, $ticketId) { 'update' => (function () use ($row, $details, $ticketId) {
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}} // Visibility changes log a flat {field, from, to} shape (api/update_ticket.php).
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?'); if (isset($details['field'], $details['from'], $details['to'])) {
$to = $details['status']['to'] ?? ($details['new_value'] ?? '?'); return "{$row['actor_name']} changed {$details['field']} on #{$ticketId}: {$details['from']}{$details['to']}";
return "{$row['actor_name']} changed status on #{$ticketId}: {$from}{$to}"; }
// Single/bulk field updates log a per-field delta, e.g.
// {"status": {"from": "Open", "to": "In Progress"}}. Only one field
// changed at a time is reported, in priority order below.
foreach (['status', 'priority', 'title', 'category', 'type', 'description'] as $field) {
if (isset($details[$field]['from'], $details[$field]['to'])) {
return "{$row['actor_name']} changed {$field} on #{$ticketId}: {$details[$field]['from']}{$details[$field]['to']}";
}
}
return "{$row['actor_name']} updated ticket #{$ticketId}";
})(), })(),
default => "{$row['actor_name']} updated ticket #{$ticketId}", default => "{$row['actor_name']} updated ticket #{$ticketId}",
}; };
+101 -3
View File
@@ -29,6 +29,73 @@ require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
/**
* Strip EXIF/metadata (including GPS) from an image file in place by
* decoding and re-encoding it via GD, which drops metadata chunks that
* aren't part of the pixel data. Best-effort: leaves the file untouched on
* any failure (corrupt image, unsupported format, GD unavailable) rather
* than blocking the upload — original bytes are what would have been stored
* anyway before this existed.
*
* download_attachment.php streams attachments back byte-for-byte to any user
* with ticket visibility, so an unstripped phone photo's embedded GPS data
* would otherwise leak a data center/office's physical location even on a
* Confidential-visibility ticket.
*/
function stripImageMetadata(string $path, string $mimeType): void
{
if (!extension_loaded('gd')) {
return;
}
// Guard against a decompression-bomb-style crafted image (small file,
// huge decoded pixel buffer) exhausting memory during decode.
$dims = @getimagesize($path);
if ($dims === false) {
return;
}
[$width, $height] = $dims;
if ($width * $height > 40_000_000) { // ~40 MP cap
return;
}
$loaders = [
'image/jpeg' => 'imagecreatefromjpeg',
'image/png' => 'imagecreatefrompng',
'image/gif' => 'imagecreatefromgif',
'image/webp' => 'imagecreatefromwebp',
];
$loader = $loaders[$mimeType] ?? null;
if ($loader === null || !function_exists($loader)) {
return;
}
$image = @$loader($path);
if ($image === false) {
return;
}
// Preserve transparency for formats that support it.
imagesavealpha($image, true);
imagealphablending($image, false);
$tmpPath = $path . '.tmp';
$saved = match ($mimeType) {
'image/jpeg' => imagejpeg($image, $tmpPath, 90),
'image/png' => imagepng($image, $tmpPath, 6),
'image/gif' => imagegif($image, $tmpPath),
'image/webp' => imagewebp($image, $tmpPath, 90),
default => false,
};
imagedestroy($image);
if ($saved && file_exists($tmpPath)) {
rename($tmpPath, $path);
} elseif (file_exists($tmpPath)) {
unlink($tmpPath);
}
}
// Check authentication // Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
ResponseHelper::unauthorized(); ResponseHelper::unauthorized();
@@ -47,6 +114,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
ResponseHelper::error('Invalid ticket ID format'); ResponseHelper::error('Invalid ticket ID format');
} }
$offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0;
$limit = isset($_GET['limit']) ? min(100, max(1, (int)$_GET['limit'])) : 40;
try { try {
$conn = Database::getConnection(); $conn = Database::getConnection();
$ticketModel = new TicketModel($conn); $ticketModel = new TicketModel($conn);
@@ -56,7 +126,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
} }
$attachmentModel = new AttachmentModel($conn); $attachmentModel = new AttachmentModel($conn);
$attachments = $attachmentModel->getAttachments($ticketId); $total = $attachmentModel->getAttachmentCount($ticketId);
$attachments = $attachmentModel->getAttachments($ticketId, $limit, $offset);
// Add formatted file size and icon to each attachment // Add formatted file size and icon to each attachment
foreach ($attachments as &$att) { foreach ($attachments as &$att) {
@@ -64,7 +135,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']); $att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
} }
ResponseHelper::success(['attachments' => $attachments]); ResponseHelper::success([
'attachments' => $attachments,
'total' => $total,
'offset' => $offset,
'limit' => $limit,
'has_more' => ($offset + $limit) < $total,
]);
} catch (Exception $e) { } catch (Exception $e) {
ResponseHelper::serverError('Failed to load attachments'); ResponseHelper::serverError('Failed to load attachments');
} }
@@ -127,6 +204,23 @@ if ($file['size'] > $maxSize) {
ResponseHelper::error('File size exceeds maximum allowed (' . AttachmentModel::formatFileSize($maxSize) . ')'); ResponseHelper::error('File size exceeds maximum allowed (' . AttachmentModel::formatFileSize($maxSize) . ')');
} }
// Check per-ticket attachment count/storage quota — bounds an authenticated
// low-privilege user slowly filling the uploads/ disk across many tickets,
// which was previously bounded only by the request-rate limiter, not volume.
$attachmentModel = new AttachmentModel($conn);
$maxAttachments = $GLOBALS['config']['MAX_ATTACHMENTS_PER_TICKET'] ?? 50;
if ($attachmentModel->getAttachmentCount($ticketId) >= $maxAttachments) {
ResponseHelper::error("This ticket already has the maximum of {$maxAttachments} attachments");
}
$maxTotalSize = $GLOBALS['config']['MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET'] ?? 104857600;
if ($attachmentModel->getTotalSizeForTicket($ticketId) + $file['size'] > $maxTotalSize) {
ResponseHelper::error(
'This upload would exceed the ticket\'s total attachment size limit of '
. AttachmentModel::formatFileSize($maxTotalSize)
);
}
// Get MIME type // Get MIME type
$finfo = new finfo(FILEINFO_MIME_TYPE); $finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']); $mimeType = $finfo->file($file['tmp_name']);
@@ -184,6 +278,11 @@ 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
if (str_starts_with($mimeType, 'image/')) {
stripImageMetadata($targetPath, $mimeType);
}
// Sanitize original filename // Sanitize original filename
$originalFilename = basename($file['name']); $originalFilename = basename($file['name']);
$originalFilename = preg_replace('/[^\w\s\-\.]/', '', $originalFilename); $originalFilename = preg_replace('/[^\w\s\-\.]/', '', $originalFilename);
@@ -193,7 +292,6 @@ if (empty($originalFilename)) {
// Save to database // Save to database
try { try {
$attachmentModel = new AttachmentModel($conn);
$attachmentId = $attachmentModel->addAttachment( $attachmentId = $attachmentModel->addAttachment(
$ticketId, $ticketId,
$uniqueFilename, $uniqueFilename,
+20 -15
View File
@@ -12,40 +12,43 @@ require_once dirname(__DIR__) . '/models/TicketModel.php';
$data = json_decode(file_get_contents('php://input'), true) ?? []; $data = json_decode(file_get_contents('php://input'), true) ?? [];
$ticketId = isset($_GET['ticket_id']) $ticketIdRaw = isset($_GET['ticket_id']) ? $_GET['ticket_id'] : ($data['ticket_id'] ?? '');
? (int)$_GET['ticket_id']
: (int)($data['ticket_id'] ?? 0);
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$ticketId = (int)($data['ticket_id'] ?? 0); $ticketIdRaw = $data['ticket_id'] ?? '';
$action = $data['action'] ?? ''; $action = $data['action'] ?? '';
if ($ticketId <= 0 || !in_array($action, ['watch', 'unwatch'], true)) { if ($ticketIdRaw === '' || !in_array($action, ['watch', 'unwatch'], true)) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid parameters']); echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
exit; exit;
} }
$ticketModel = new TicketModel($conn); $ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId); $ticket = $ticketModel->getTicketById((string)$ticketIdRaw);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) { if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404); http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']); echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit; exit;
} }
// Use the canonical ticket_id string from the fetched ticket row, not the
// raw request value, so ticket_watchers always stores exactly what's in
// tickets.ticket_id.
$ticketId = $ticket['ticket_id'];
if ($action === 'watch') { if ($action === 'watch') {
$stmt = $conn->prepare( $stmt = $conn->prepare(
"INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)" "INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)"
); );
$stmt->bind_param("ii", $ticketId, $userId); $stmt->bind_param("si", $ticketId, $userId);
$stmt->execute(); $stmt->execute();
$stmt->close(); $stmt->close();
} else { } else {
$stmt = $conn->prepare( $stmt = $conn->prepare(
"DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?" "DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
); );
$stmt->bind_param("ii", $ticketId, $userId); $stmt->bind_param("si", $ticketId, $userId);
$stmt->execute(); $stmt->execute();
$stmt->close(); $stmt->close();
} }
@@ -54,7 +57,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$countStmt = $conn->prepare( $countStmt = $conn->prepare(
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?" "SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
); );
$countStmt->bind_param("i", $ticketId); $countStmt->bind_param("s", $ticketId);
$countStmt->execute(); $countStmt->execute();
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt']; $count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
$countStmt->close(); $countStmt->close();
@@ -73,7 +76,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
exit; exit;
} }
if ($ticketId <= 0) { if ($ticketIdRaw === '') {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ticket_id required']); echo json_encode(['success' => false, 'error' => 'ticket_id required']);
exit; exit;
@@ -83,17 +86,19 @@ if ($ticketId <= 0) {
// restricted ticket's watcher list and count aren't disclosed (the POST path // restricted ticket's watcher list and count aren't disclosed (the POST path
// already checks this). // already checks this).
$ticketModel = new TicketModel($conn); $ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId); $ticket = $ticketModel->getTicketById((string)$ticketIdRaw);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) { if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404); http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']); echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit; exit;
} }
$ticketId = $ticket['ticket_id'];
$watchingStmt = $conn->prepare( $watchingStmt = $conn->prepare(
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?" "SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
); );
$watchingStmt->bind_param("ii", $ticketId, $userId); $watchingStmt->bind_param("si", $ticketId, $userId);
$watchingStmt->execute(); $watchingStmt->execute();
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt']; $watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
$watchingStmt->close(); $watchingStmt->close();
@@ -107,7 +112,7 @@ $watchersStmt = $conn->prepare(
ORDER BY tw.created_at ASC ORDER BY tw.created_at ASC
LIMIT 6" LIMIT 6"
); );
$watchersStmt->bind_param("i", $ticketId); $watchersStmt->bind_param("s", $ticketId);
$watchersStmt->execute(); $watchersStmt->execute();
$watchersResult = $watchersStmt->get_result(); $watchersResult = $watchersStmt->get_result();
$watchers = []; $watchers = [];
@@ -118,7 +123,7 @@ $watchersStmt->close();
// True watcher count (the list above is capped at 6 for the avatar group) // True watcher count (the list above is capped at 6 for the avatar group)
$countStmt = $conn->prepare("SELECT COUNT(*) AS cnt FROM ticket_watchers WHERE ticket_id = ?"); $countStmt = $conn->prepare("SELECT COUNT(*) AS cnt FROM ticket_watchers WHERE ticket_id = ?");
$countStmt->bind_param("i", $ticketId); $countStmt->bind_param("s", $ticketId);
$countStmt->execute(); $countStmt->execute();
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt']; $count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
$countStmt->close(); $countStmt->close();
+17 -8
View File
@@ -87,11 +87,17 @@ function performAdvancedSearch(event) {
params.set('search', searchText); params.set('search', searchText);
} }
// Date ranges // Date ranges — swap if the user entered an end date before the start date
const createdFrom = document.getElementById('adv-created-from').value; let createdFrom = document.getElementById('adv-created-from').value;
const createdTo = document.getElementById('adv-created-to').value; let createdTo = document.getElementById('adv-created-to').value;
const updatedFrom = document.getElementById('adv-updated-from').value; if (createdFrom && createdTo && createdFrom > createdTo) {
const updatedTo = document.getElementById('adv-updated-to').value; [createdFrom, createdTo] = [createdTo, createdFrom];
}
let updatedFrom = document.getElementById('adv-updated-from').value;
let updatedTo = document.getElementById('adv-updated-to').value;
if (updatedFrom && updatedTo && updatedFrom > updatedTo) {
[updatedFrom, updatedTo] = [updatedTo, updatedFrom];
}
if (createdFrom) params.set('created_from', createdFrom); if (createdFrom) params.set('created_from', createdFrom);
if (createdTo) params.set('created_to', createdTo); if (createdTo) params.set('created_to', createdTo);
@@ -105,9 +111,12 @@ function performAdvancedSearch(event) {
params.set('status', selectedStatuses.join(',')); params.set('status', selectedStatuses.join(','));
} }
// Priority range // Priority range — swap if min > max so the range is always satisfiable
const priorityMin = document.getElementById('adv-priority-min').value; let priorityMin = document.getElementById('adv-priority-min').value;
const priorityMax = document.getElementById('adv-priority-max').value; let priorityMax = document.getElementById('adv-priority-max').value;
if (priorityMin && priorityMax && Number(priorityMin) > Number(priorityMax)) {
[priorityMin, priorityMax] = [priorityMax, priorityMin];
}
if (priorityMin) params.set('priority_min', priorityMin); if (priorityMin) params.set('priority_min', priorityMin);
if (priorityMax) params.set('priority_max', priorityMax); if (priorityMax) params.set('priority_max', priorityMax);
+95
View File
@@ -2475,6 +2475,101 @@
list.addEventListener('drop', e => { e.preventDefault(); }); list.addEventListener('drop', e => { e.preventDefault(); });
// Touch fallback — iOS Safari doesn't implement HTML5 drag-and-drop on
// arbitrary elements at all, and mobile Chrome's support is poor, so
// kanban drag was effectively unusable via touch without this. Touch
// events for a given touch point are always dispatched to the element
// touchstart fired on (per spec), so per-list local state here is safe;
// cross-list moves are resolved via elementFromPoint against the live
// finger position, same as dragover does via e.target above.
const DRAG_THRESHOLD = 8; // px of movement before a touch starts a drag
let _touchItem = null, _touchDragging = false;
let _touchStartX = 0, _touchStartY = 0, _touchOffsetX = 0, _touchOffsetY = 0;
function _touchTargetList(x, y) {
const el = document.elementFromPoint(x, y);
const found = el ? el.closest('[data-sortable-group]') : null;
return found && (found === list || _sameGroup(found)) ? found : null;
}
list.addEventListener('touchstart', e => {
const item = e.target.closest('[data-sortable-item]');
if (!item || !list.contains(item)) return;
if (handle && !e.target.closest(handle)) return;
const t = e.touches[0];
_touchItem = item;
_touchDragging = false;
_touchStartX = t.clientX;
_touchStartY = t.clientY;
}, { passive: true });
// touchmove/touchend/touchcancel are registered on document, not list:
// once the dragged item is reparented to document.body below, it's no
// longer a descendant of list, so events targeting it (touch events
// keep targeting their touchstart element for the whole gesture) would
// stop bubbling to a listener on list.
document.addEventListener('touchmove', e => {
if (!_touchItem) return;
const t = e.touches[0];
if (!_touchDragging) {
if (Math.abs(t.clientX - _touchStartX) < DRAG_THRESHOLD && Math.abs(t.clientY - _touchStartY) < DRAG_THRESHOLD) return;
// Drag intent confirmed — take over from here, blocking page scroll.
_touchDragging = true;
_srtDragging = _touchItem;
_srtSrcList = list;
_srtPlaceholder = _makePlaceholder(_touchItem);
_touchItem.classList.add('is-dragging');
const rect = _touchItem.getBoundingClientRect();
_touchOffsetX = _touchStartX - rect.left;
_touchOffsetY = _touchStartY - rect.top;
_touchItem.parentNode.insertBefore(_srtPlaceholder, _touchItem);
_touchItem.style.position = 'fixed';
_touchItem.style.zIndex = '1000';
_touchItem.style.width = rect.width + 'px';
_touchItem.style.pointerEvents = 'none';
document.body.appendChild(_touchItem); // avoid clipping by an overflow:hidden ancestor
}
e.preventDefault();
_touchItem.style.left = (t.clientX - _touchOffsetX) + 'px';
_touchItem.style.top = (t.clientY - _touchOffsetY) + 'px';
const targetList = _touchTargetList(t.clientX, t.clientY);
if (!targetList) return;
const overEl = document.elementFromPoint(t.clientX, t.clientY);
const over = overEl ? overEl.closest('[data-sortable-item]') : null;
if (over && over !== _srtDragging && targetList.contains(over)) {
const rect = over.getBoundingClientRect();
targetList.insertBefore(_srtPlaceholder, t.clientY < rect.top + rect.height / 2 ? over : over.nextSibling);
} else if (!targetList.contains(_srtPlaceholder)) {
targetList.appendChild(_srtPlaceholder);
}
}, { passive: false });
function _touchEnd() {
if (_touchDragging && _srtDragging) {
_srtDragging.classList.remove('is-dragging');
_srtDragging.style.position = '';
_srtDragging.style.zIndex = '';
_srtDragging.style.width = '';
_srtDragging.style.pointerEvents = '';
_srtDragging.style.left = '';
_srtDragging.style.top = '';
if (_srtPlaceholder && _srtPlaceholder.parentNode) {
_srtPlaceholder.parentNode.insertBefore(_srtDragging, _srtPlaceholder);
_srtPlaceholder.remove();
}
if (onSort) onSort(_getItems(), _srtDragging);
bus.emit('sortable:change', { list, items: _getItems(), moved: _srtDragging });
}
_touchItem = null; _touchDragging = false;
_srtDragging = null; _srtPlaceholder = null; _srtSrcList = null;
}
document.addEventListener('touchend', _touchEnd);
document.addEventListener('touchcancel', _touchEnd);
return { return {
refresh() { Array.from(list.children).forEach(child => { if (!child.hasAttribute('data-sortable-item')) _mark(child); }); }, refresh() { Array.from(list.children).forEach(child => { if (!child.hasAttribute('data-sortable-item')) _mark(child); }); },
getOrder: () => _getItems().map(el => el.dataset.id || el.textContent.trim()), getOrder: () => _getItems().map(el => el.dataset.id || el.textContent.trim()),
+17 -85
View File
@@ -297,8 +297,12 @@ function clearAllFilters() {
params.delete('type'); params.delete('type');
params.delete('assigned_to'); params.delete('assigned_to');
params.delete('search'); params.delete('search');
params.delete('date_from'); params.delete('created_from');
params.delete('date_to'); params.delete('created_to');
params.delete('updated_from');
params.delete('updated_to');
params.delete('closed_from');
params.delete('closed_to');
params.delete('page'); params.delete('page');
// Keep sort parameters // Keep sort parameters
@@ -357,14 +361,9 @@ function initSidebarFilters() {
} }
if (clearFiltersBtn) { if (clearFiltersBtn) {
clearFiltersBtn.addEventListener('click', () => { // Delegate to clearAllFilters() so both controls always clear the same
const params = new URLSearchParams(window.location.search); // complete set of filter params instead of two independently-maintained lists.
['status','category','type', clearFiltersBtn.addEventListener('click', clearAllFilters);
'created_from','created_to','updated_from','updated_to','closed_from','closed_to'
].forEach(k => params.delete(k));
params.set('page', '1');
window.location.search = params.toString();
});
} }
} }
@@ -381,73 +380,6 @@ function initSettingsModal() {
} }
} }
function sortTable(table, column) {
const headers = table.querySelectorAll('th');
headers.forEach(header => {
header.classList.remove('sort-asc', 'sort-desc');
});
const rows = Array.from(table.querySelectorAll('tbody tr'));
const currentDirection = table.dataset.sortColumn == column
? (table.dataset.sortDirection === 'asc' ? 'desc' : 'asc')
: 'asc';
table.dataset.sortColumn = column;
table.dataset.sortDirection = currentDirection;
rows.sort((a, b) => {
const aValue = a.children[column].textContent.trim();
const bValue = b.children[column].textContent.trim();
// Check if this is a date column — prefer data-ts attribute over text (which may be relative)
const headerText = headers[column].textContent.toLowerCase();
if (headerText === 'created' || headerText === 'updated') {
const cellA = a.children[column];
const cellB = b.children[column];
const dateA = new Date(cellA.dataset.ts || aValue);
const dateB = new Date(cellB.dataset.ts || bValue);
return currentDirection === 'asc' ? dateA - dateB : dateB - dateA;
}
// Special handling for "Assigned To" column
if (headerText === 'assigned to') {
const aUnassigned = aValue === 'Unassigned';
const bUnassigned = bValue === 'Unassigned';
// Both unassigned - equal
if (aUnassigned && bUnassigned) return 0;
// Put unassigned at the end regardless of sort direction
if (aUnassigned) return 1;
if (bUnassigned) return -1;
// Otherwise sort names normally
return currentDirection === 'asc'
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
}
// Numeric comparison
const numA = parseFloat(aValue);
const numB = parseFloat(bValue);
if (!isNaN(numA) && !isNaN(numB)) {
return currentDirection === 'asc' ? numA - numB : numB - numA;
}
// String comparison
return currentDirection === 'asc'
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
});
const currentHeader = headers[column];
currentHeader.classList.add(currentDirection === 'asc' ? 'sort-asc' : 'sort-desc');
const tbody = table.querySelector('tbody');
rows.forEach(row => tbody.appendChild(row));
}
// Old settings modal functions removed - now using settings.js with new settings modal // Old settings modal functions removed - now using settings.js with new settings modal
@@ -1135,12 +1067,11 @@ function quickAssign(ticketId) {
<div class="lt-modal-body"> <div class="lt-modal-body">
<p class="lt-mb-xs lt-text-muted lt-text-xs">Ticket #${lt.escHtml(String(ticketId))}</p> <p class="lt-mb-xs lt-text-muted lt-text-xs">Ticket #${lt.escHtml(String(ticketId))}</p>
<label class="lt-label">Assign to:</label> <label class="lt-label">Assign to:</label>
<div class="lt-combobox" id="quickAssignCombobox"> <div class="lt-typeahead" id="quickAssignTypeahead" style="position:relative">
<div class="lt-combobox-input-wrap"> <input type="text" class="lt-input lt-w-full" id="quickAssignInput"
<input type="text" class="lt-combobox-input" id="quickAssignInput" placeholder="Search users…" autocomplete="off" spellcheck="false"
placeholder="Search users" autocomplete="off" aria-label="Search users"> aria-label="Search users" aria-autocomplete="list">
</div> <div class="lt-typeahead-dropdown" id="quickAssignDropdown"></div>
<ul class="lt-combobox-list" role="listbox" aria-hidden="true"></ul>
</div> </div>
</div> </div>
<div class="lt-modal-footer"> <div class="lt-modal-footer">
@@ -1166,7 +1097,9 @@ function quickAssign(ticketId) {
label: u.display_name || u.username label: u.display_name || u.username
})) }))
]; ];
lt.combobox.init(input, items, { lt.typeahead.init(input, items, {
minChars: 1,
maxResults: 8,
onSelect: function(item) { _quickAssignUserId = item.value || null; } onSelect: function(item) { _quickAssignUserId = item.value || null; }
}); });
} }
@@ -1215,7 +1148,6 @@ function setViewMode(mode) {
if (mode === 'card') { if (mode === 'card') {
populateKanbanCards(); populateKanbanCards();
} }
localStorage.setItem('ticketViewMode', mode);
} }
/** /**
+36 -11
View File
@@ -354,6 +354,33 @@ window.renderMarkdownElements = renderMarkdownElements;
// Rich Text Editor Toolbar Functions // Rich Text Editor Toolbar Functions
// ======================================== // ========================================
/**
* Replace textarea.value.substring(selStart, selEnd) with replacementText,
* preserving the browser's native undo/redo stack via
* document.execCommand('insertText', ...) -- the same mechanism real typing
* uses -- instead of a direct .value assignment, which discards the entire
* undo history. Falls back to a direct assignment (losing undo, matching the
* old behavior) only if execCommand is unavailable or unsuccessful.
*/
function insertTextPreservingUndo(textarea, replacementText, selStart, selEnd) {
textarea.focus();
textarea.setSelectionRange(selStart, selEnd);
let inserted = false;
if (typeof document.execCommand === 'function') {
try {
inserted = document.execCommand('insertText', false, replacementText);
} catch (e) {
inserted = false;
}
}
if (!inserted) {
const text = textarea.value;
textarea.value = text.substring(0, selStart) + replacementText + text.substring(selEnd);
}
}
/** /**
* Insert markdown formatting around selection * Insert markdown formatting around selection
*/ */
@@ -363,16 +390,13 @@ function insertMarkdownFormat(textareaId, prefix, suffix) {
const start = textarea.selectionStart; const start = textarea.selectionStart;
const end = textarea.selectionEnd; const end = textarea.selectionEnd;
const text = textarea.value; const selectedText = textarea.value.substring(start, end);
const selectedText = text.substring(start, end);
// Insert formatting insertTextPreservingUndo(textarea, prefix + selectedText + suffix, start, end);
const newText = text.substring(0, start) + prefix + selectedText + suffix + text.substring(end);
textarea.value = newText;
// Set cursor position // Set cursor position
if (selectedText) { if (selectedText) {
textarea.setSelectionRange(start + prefix.length, end + prefix.length); textarea.setSelectionRange(start + prefix.length, start + prefix.length + selectedText.length);
} else { } else {
textarea.setSelectionRange(start + prefix.length, start + prefix.length); textarea.setSelectionRange(start + prefix.length, start + prefix.length);
} }
@@ -391,9 +415,10 @@ function insertMarkdownText(textareaId, text) {
if (!textarea) return; if (!textarea) return;
const start = textarea.selectionStart; const start = textarea.selectionStart;
const value = textarea.value;
textarea.value = value.substring(0, start) + text + value.substring(start); // Matches the prior behavior: insert before the selection start without
// deleting any currently-selected text (a collapsed replace range).
insertTextPreservingUndo(textarea, text, start, start);
textarea.setSelectionRange(start + text.length, start + text.length); textarea.setSelectionRange(start + text.length, start + text.length);
textarea.focus(); textarea.focus();
@@ -453,7 +478,7 @@ function toolbarList(textareaId) {
} }
// Insert list marker at beginning of line // Insert list marker at beginning of line
textarea.value = text.substring(0, lineStart) + '- ' + text.substring(lineStart); insertTextPreservingUndo(textarea, '- ', lineStart, lineStart);
textarea.setSelectionRange(start + 2, start + 2); textarea.setSelectionRange(start + 2, start + 2);
textarea.focus(); textarea.focus();
@@ -474,7 +499,7 @@ function toolbarHeading(textareaId) {
} }
// Insert heading marker at beginning of line // Insert heading marker at beginning of line
textarea.value = text.substring(0, lineStart) + '## ' + text.substring(lineStart); insertTextPreservingUndo(textarea, '## ', lineStart, lineStart);
textarea.setSelectionRange(start + 3, start + 3); textarea.setSelectionRange(start + 3, start + 3);
textarea.focus(); textarea.focus();
@@ -495,7 +520,7 @@ function toolbarQuote(textareaId) {
} }
// Insert quote marker at beginning of line // Insert quote marker at beginning of line
textarea.value = text.substring(0, lineStart) + '> ' + text.substring(lineStart); insertTextPreservingUndo(textarea, '> ', lineStart, lineStart);
textarea.setSelectionRange(start + 2, start + 2); textarea.setSelectionRange(start + 2, start + 2);
textarea.focus(); textarea.focus();
+229 -20
View File
@@ -183,15 +183,35 @@ function toggleEditMode() {
} }
/** /**
* Compute avatar color class from display name (mirrors PHP crc32 % 4 logic) * CRC-32 (IEEE 802.3 / zlib polynomial), matching PHP's crc32(). Operates on
* the UTF-8 byte sequence, same as PHP, so results agree for non-ASCII names.
*/
function crc32(str) {
var bytes = unescape(encodeURIComponent(str));
var table = crc32._table || (crc32._table = (function () {
var t = [];
for (var n = 0; n < 256; n++) {
var c = n;
for (var k = 0; k < 8; k++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
t[n] = c;
}
return t;
})());
var crc = -1;
for (var i = 0; i < bytes.length; i++) {
crc = (crc >>> 8) ^ table[(crc ^ bytes.charCodeAt(i)) & 0xFF];
}
return (crc ^ -1) >>> 0;
}
/**
* Compute avatar color class from display name (mirrors PHP's crc32 % 4 logic)
*/ */
function avatarColorClass(displayName) { function avatarColorClass(displayName) {
var colors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', '']; var colors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
var h = 0; return colors[crc32(displayName) % 4];
for (var i = 0; i < displayName.length; i++) {
h = ((h << 5) - h + displayName.charCodeAt(i)) | 0;
}
return colors[Math.abs(h) % 4];
} }
/** /**
@@ -432,6 +452,140 @@ function handleAssignmentChange() {
}); });
} }
// ========================================
// SLA Priority-Alert Banner
// ========================================
const SLA_TARGET_HOURS = { 1: 8, 2: 24 };
const SLA_META = {
1: { cls: 'lt-sla-p1', icon: '[ ! ]', label: 'P1 Critical' },
2: { cls: 'lt-sla-p2', icon: '[ ~ ]', label: 'P2 High' },
};
let slaTickTimer = null;
function stopSlaTicker() {
if (slaTickTimer) {
clearInterval(slaTickTimer);
slaTickTimer = null;
}
}
function startSlaTicker(banner) {
stopSlaTicker();
const createdAt = parseInt(banner.dataset.createdAt, 10) * 1000;
const slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
const deadline = createdAt + slaMs;
const elapsedEl = document.getElementById('slaElapsedTimer');
const countdownEl = document.getElementById('slaCountdownTimer');
const overrunEl = document.getElementById('slaOverrunTimer');
const fillBar = document.getElementById('slaProgressBar');
const progressWrap = document.getElementById('slaProgress');
function fmtHMS(ms) {
const s = Math.floor(Math.abs(ms) / 1000);
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
return [h, m, ss].map(n => String(n).padStart(2, '0')).join(':');
}
function tick() {
const now = Date.now();
const elapsed = now - createdAt;
const remaining = deadline - now;
const pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
if (fillBar) fillBar.style.width = pct + '%';
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
if (remaining > 0) {
if (countdownEl) countdownEl.textContent = fmtHMS(remaining) + ' remaining';
} else if (overrunEl) {
overrunEl.textContent = fmtHMS(-remaining);
}
}
tick();
slaTickTimer = setInterval(tick, 1000);
}
/**
* Render, update, or remove the SLA priority-alert banner for the given
* priority, matching what a fresh page load would show. Called on initial
* load and again whenever the ticket's priority changes client-side, so the
* banner never goes stale until a reload.
*/
function renderSlaBanner(priorityNum) {
const anchor = document.getElementById('priorityAlertBannerAnchor');
const existing = document.getElementById('priorityAlertBanner');
const meta = SLA_META[priorityNum];
const status = window.ticketData && window.ticketData.status;
if (!meta || status === 'Closed') {
if (existing) {
stopSlaTicker();
existing.remove();
}
return;
}
const createdAtSec = window.ticketData && window.ticketData.created_at_ts;
if (!createdAtSec || !anchor) return;
const slaTargetHours = SLA_TARGET_HOURS[priorityNum];
const elapsedSeconds = Math.floor(Date.now() / 1000) - createdAtSec;
const slaBreached = elapsedSeconds >= slaTargetHours * 3600;
const slaPct = Math.min(100, Math.round((elapsedSeconds / (slaTargetHours * 3600)) * 100));
const slaId = 'sla-' + window.ticketData.id;
let dismissed = false;
try {
dismissed = !!sessionStorage.getItem('lt_sla_dismissed_' + slaId);
} catch (e) { /* sessionStorage unavailable */ }
const banner = existing || document.createElement('div');
if (!existing) {
banner.id = 'priorityAlertBanner';
banner.setAttribute('role', 'alert');
banner.setAttribute('aria-live', 'polite');
banner.style.marginBottom = '0.75rem';
anchor.appendChild(banner);
}
banner.className = meta.cls;
banner.dataset.slaId = slaId;
banner.dataset.createdAt = String(createdAtSec);
banner.dataset.slaHours = String(slaTargetHours);
banner.hidden = dismissed;
banner.innerHTML =
`<span class="lt-sla-icon" aria-hidden="true">${meta.icon}</span>` +
'<div class="lt-sla-info">' +
`<div class="lt-sla-title">${lt.escHtml(meta.label)} — SLA: <span id="slaElapsedTimer"></span> elapsed of ${slaTargetHours}h limit` +
(slaBreached ? '&nbsp;<span class="lt-text-danger" id="slaBreachLabel">BREACHED</span>' : '') +
'</div>' +
`<div class="lt-sla-bar" aria-label="SLA progress ${slaPct}%" id="slaProgress">` +
`<div class="lt-sla-fill" id="slaProgressBar" style="width:${slaPct}%"></div>` +
'</div>' +
'</div>' +
(slaBreached
? `<div class="lt-sla-meta lt-text-danger" id="slaCountdownTimer">+<span id="slaOverrunTimer">${Math.round((elapsedSeconds - slaTargetHours * 3600) / 360) / 10}h</span> over</div>`
: '<div class="lt-sla-meta" id="slaCountdownTimer"></div>') +
'<button type="button" class="lt-sla-dismiss" aria-label="Dismiss">&#x2715;</button>';
banner.querySelector('.lt-sla-dismiss').addEventListener('click', function() {
banner.hidden = true;
stopSlaTicker();
try { sessionStorage.setItem('lt_sla_dismissed_' + slaId, '1'); } catch (e) { /* ignore */ }
});
if (dismissed) {
stopSlaTicker();
} else {
startSlaTicker(banner);
}
}
/** /**
* Handle metadata field changes (priority, category, type) * Handle metadata field changes (priority, category, type)
*/ */
@@ -455,10 +609,12 @@ function handleMetadataChanges() {
// Update window.ticketData // Update window.ticketData
window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue; window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue;
// For priority, update the TDS frame border accent // For priority, update the TDS frame border accent and the
// SLA banner (which otherwise stays stale until reload)
if (fieldName === 'priority') { if (fieldName === 'priority') {
const ticketFrame = document.querySelector('.lt-frame-ticket'); const ticketFrame = document.querySelector('.lt-frame-ticket');
if (ticketFrame) ticketFrame.setAttribute('data-priority', newValue); if (ticketFrame) ticketFrame.setAttribute('data-priority', newValue);
renderSlaBanner(window.ticketData.priority);
} }
} }
}) })
@@ -1007,35 +1163,62 @@ function resetUploadUI() {
} }
} }
function loadAttachments() { const ATTACHMENT_PAGE_SIZE = 40;
const ticketId = window.ticketData.id; let attachmentOffset = 0;
const container = document.getElementById('attachmentsList'); let attachmentTotal = 0;
function loadAttachments() {
const container = document.getElementById('attachmentsList');
if (!container) return; if (!container) return;
lt.api.get(`/api/upload_attachment.php?ticket_id=${ticketId}`) attachmentOffset = 0;
attachmentTotal = 0;
fetchAttachmentsPage(false);
}
function fetchAttachmentsPage(append) {
const ticketId = window.ticketData.id;
const container = document.getElementById('attachmentsList');
if (!container) return;
const loadMoreBtn = document.getElementById('attachmentsLoadMoreBtn');
if (loadMoreBtn) {
loadMoreBtn.disabled = true;
loadMoreBtn.textContent = 'Loading…';
}
lt.api.get(`/api/upload_attachment.php?ticket_id=${ticketId}&offset=${attachmentOffset}&limit=${ATTACHMENT_PAGE_SIZE}`)
.then(data => { .then(data => {
if (data.success) { if (data.success) {
renderAttachments(data.attachments || []); attachmentTotal = data.total;
} else { attachmentOffset += (data.attachments || []).length;
renderAttachments(data.attachments || [], append, data.has_more);
} else if (!append) {
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>'; container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
} else {
lt.toast.error('Error loading more attachments');
} }
}) })
.catch(error => { .catch(error => {
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>'; if (!append) {
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
} else {
lt.toast.error('Error loading more attachments');
}
}); });
} }
function renderAttachments(attachments) { function renderAttachments(attachments, append, hasMore) {
const container = document.getElementById('attachmentsList'); const container = document.getElementById('attachmentsList');
if (!container) return; if (!container) return;
if (attachments.length === 0) { if (!append && attachments.length === 0) {
container.innerHTML = '<p class="lt-text-muted">No files attached to this ticket.</p>'; container.innerHTML = '<p class="lt-text-muted">No files attached to this ticket.</p>';
return; return;
} }
let html = '<div class="attachments-grid">'; let grid = append ? container.querySelector('.attachments-grid') : null;
let html = '';
attachments.forEach(att => { attachments.forEach(att => {
const uploaderName = att.display_name || att.username || 'Unknown'; const uploaderName = att.display_name || att.username || 'Unknown';
@@ -1048,7 +1231,7 @@ function renderAttachments(attachments) {
}); });
const uploadDate = `<span class="ts-cell" data-ts="${lt.escHtml(att.uploaded_at)}" title="${lt.escHtml(uploadDateFormatted)}">${lt.time.ago(att.uploaded_at)}</span>`; const uploadDate = `<span class="ts-cell" data-ts="${lt.escHtml(att.uploaded_at)}" title="${lt.escHtml(uploadDateFormatted)}">${lt.time.ago(att.uploaded_at)}</span>`;
const isImage = /\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(att.original_filename); const isImage = /^image\//i.test(att.mime_type || '');
const imgUrl = `/api/download_attachment.php?id=${att.attachment_id}&inline=1`; const imgUrl = `/api/download_attachment.php?id=${att.attachment_id}&inline=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)}">
@@ -1075,8 +1258,34 @@ function renderAttachments(attachments) {
</div>`; </div>`;
}); });
html += '</div>'; if (grid) {
container.innerHTML = html; const temp = document.createElement('div');
temp.innerHTML = html;
while (temp.firstChild) {
grid.appendChild(temp.firstChild);
}
} else {
container.innerHTML = '<div class="attachments-grid">' + html + '</div>';
}
const remaining = attachmentTotal - attachmentOffset;
let loadMoreBtn = document.getElementById('attachmentsLoadMoreBtn');
if (hasMore && remaining > 0) {
if (!loadMoreBtn) {
loadMoreBtn = document.createElement('button');
loadMoreBtn.type = 'button';
loadMoreBtn.id = 'attachmentsLoadMoreBtn';
loadMoreBtn.className = 'lt-btn lt-btn-sm lt-w-full';
loadMoreBtn.style.marginTop = '0.6rem';
loadMoreBtn.addEventListener('click', function() { fetchAttachmentsPage(true); });
container.appendChild(loadMoreBtn);
}
loadMoreBtn.disabled = false;
loadMoreBtn.textContent = `Load more attachments (${remaining} remaining)`;
} else if (loadMoreBtn) {
loadMoreBtn.remove();
}
// Initialize lightbox on image thumbnails // Initialize lightbox on image thumbnails
if (window.lt && lt.lightbox) { if (window.lt && lt.lightbox) {
lt.lightbox.init('.lt-lightbox-trigger', { caption: 'title', loop: true }); lt.lightbox.init('.lt-lightbox-trigger', { caption: 'title', loop: true });
+2
View File
@@ -115,6 +115,8 @@ $GLOBALS['config'] = [
// File upload settings // File upload settings
'MAX_UPLOAD_SIZE' => 10485760, // 10MB in bytes 'MAX_UPLOAD_SIZE' => 10485760, // 10MB in bytes
'MAX_ATTACHMENTS_PER_TICKET' => 50,
'MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET' => 104857600, // 100MB in bytes
'ALLOWED_FILE_TYPES' => [ 'ALLOWED_FILE_TYPES' => [
'image/jpeg', 'image/jpeg',
'image/png', 'image/png',
+7
View File
@@ -25,4 +25,11 @@ return [
'fileinfo', // api/upload_attachment.php — MIME validation 'fileinfo', // api/upload_attachment.php — MIME validation
'json', // request/response encoding (bundled, but assert anyway) 'json', // request/response encoding (bundled, but assert anyway)
], ],
// Sanity-check thresholds (warnings, not hard failures). A host with a low
// default memory_limit passes a bare extension/version check cleanly and
// only surfaces as a mysterious failure under real load — a large CSV
// export, an oversized dashboard query on a big install.
'min_memory_limit_mb' => 256,
'min_max_execution_time' => 30, // seconds; 0 (unlimited) always passes
]; ];
+3
View File
@@ -5,6 +5,9 @@ header('Content-Type: application/json');
error_reporting(E_ALL); error_reporting(E_ALL);
ini_set('display_errors', 0); ini_set('display_errors', 0);
require_once __DIR__ . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
// Load environment variables with error check // Load environment variables with error check
$envFile = __DIR__ . '/.env'; $envFile = __DIR__ . '/.env';
if (!file_exists($envFile)) { if (!file_exists($envFile)) {
+36 -1
View File
@@ -121,6 +121,33 @@ class CacheHelper
return $written; return $written;
} }
/**
* Read the current invalidation epoch for a prefix (0 if never bumped).
* Used by remember() to detect an invalidation that happened while a
* cache-miss recomputation was in flight.
*/
private static function getEpoch(string $prefix): int
{
$safePrefix = preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix);
$file = self::getCacheDir() . '/' . $safePrefix . '.epoch';
$val = @file_get_contents($file);
return $val !== false ? (int)$val : 0;
}
/**
* Bump a prefix's invalidation epoch. Called whenever anything under the
* prefix is invalidated.
*/
private static function bumpEpoch(string $prefix): void
{
$safePrefix = preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix);
$file = self::getCacheDir() . '/' . $safePrefix . '.epoch';
$next = self::getEpoch($prefix) + 1;
if (@file_put_contents($file, (string)$next, LOCK_EX) !== false) {
@chmod($file, 0600);
}
}
/** /**
* Delete cached data * Delete cached data
* *
@@ -130,6 +157,8 @@ class CacheHelper
*/ */
public static function delete(string $prefix, $identifier = null): bool public static function delete(string $prefix, $identifier = null): bool
{ {
self::bumpEpoch($prefix);
if ($identifier !== null) { if ($identifier !== null) {
$key = self::makeKey($prefix, $identifier); $key = self::makeKey($prefix, $identifier);
unset(self::$memoryCache[$key]); unset(self::$memoryCache[$key]);
@@ -192,8 +221,14 @@ class CacheHelper
$data = self::get($prefix, $identifier, $ttl); $data = self::get($prefix, $identifier, $ttl);
if ($data === null) { if ($data === null) {
// Snapshot the epoch before running the (possibly slow) callback so
// a concurrent invalidation mid-computation can be detected below —
// otherwise this request's stale pre-invalidation result could
// overwrite a newer request's fresher write, extending staleness by
// up to another full TTL.
$epochBefore = self::getEpoch($prefix);
$data = $callback(); $data = $callback();
if ($data !== null) { if ($data !== null && self::getEpoch($prefix) === $epochBefore) {
self::set($prefix, $identifier, $data); self::set($prefix, $identifier, $data);
} }
} }
+2 -2
View File
@@ -204,9 +204,9 @@ class NotificationHelper
return; return;
} }
if ($excludeUserId !== null) { if ($excludeUserId !== null) {
$stmt->bind_param("ii", $ticketId, $excludeUserId); $stmt->bind_param("si", $ticketId, $excludeUserId);
} else { } else {
$stmt->bind_param("i", $ticketId); $stmt->bind_param("s", $ticketId);
} }
$stmt->execute(); $stmt->execute();
$result = $stmt->get_result(); $result = $stmt->get_result();
-212
View File
@@ -1,212 +0,0 @@
<?php
/**
* OutputHelper - Consistent output escaping utilities
*
* Provides secure HTML escaping functions to prevent XSS attacks.
* Use these functions when outputting user-controlled data.
*/
class OutputHelper
{
/**
* Escape string for HTML output
*
* Use for text content inside HTML elements.
* Example: <p><?= OutputHelper::h($userInput) ?></p>
*
* @param string|null $string The string to escape
* @param int $flags htmlspecialchars flags (default: ENT_QUOTES | ENT_HTML5)
* @return string Escaped string
*/
public static function h(?string $string, int $flags = ENT_QUOTES | ENT_HTML5): string
{
if ($string === null) {
return '';
}
return htmlspecialchars($string, $flags, 'UTF-8');
}
/**
* Escape string for HTML attribute context
*
* Use for values inside HTML attributes.
* Example: <input value="<?= OutputHelper::attr($userInput) ?>">
*
* @param string|null $string The string to escape
* @return string Escaped string
*/
public static function attr(?string $string): string
{
if ($string === null) {
return '';
}
// More aggressive escaping for attribute context
return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5 | ENT_SUBSTITUTE, 'UTF-8');
}
/**
* Encode data as JSON for JavaScript context
*
* Use when embedding data in JavaScript.
* Example: <script>const data = <?= OutputHelper::json($data) ?>;</script>
*
* @param mixed $data The data to encode
* @param int $flags json_encode flags
* @return string JSON encoded string (safe for script context)
*/
public static function json($data, int $flags = 0): string
{
// Use HEX encoding for safety in HTML context
$safeFlags = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | $flags;
return json_encode($data, $safeFlags);
}
/**
* URL encode a string
*
* Use for values in URL query strings.
* Example: <a href="/search?q=<?= OutputHelper::url($query) ?>">
*
* @param string|null $string The string to encode
* @return string URL encoded string
*/
public static function url(?string $string): string
{
if ($string === null) {
return '';
}
return rawurlencode($string);
}
/**
* Escape for CSS context
*
* Use for values in inline CSS.
* Example: <div style="color: <?= OutputHelper::css($color) ?>;">
*
* @param string|null $string The string to escape
* @return string Escaped string (only allows safe characters)
*/
public static function css(?string $string): string
{
if ($string === null) {
return '';
}
// Only allow alphanumeric, hyphens, underscores, spaces, and common CSS values
if (!preg_match('/^[a-zA-Z0-9_\-\s#.,()%]+$/', $string)) {
return '';
}
return $string;
}
/**
* Format a number safely
*
* Ensures output is always a valid number.
*
* @param mixed $number The number to format
* @param int $decimals Number of decimal places
* @return string Formatted number
*/
public static function number($number, int $decimals = 0): string
{
return number_format((float)$number, $decimals, '.', ',');
}
/**
* Format an integer safely
*
* @param mixed $value The value to format
* @return int Integer value
*/
public static function int($value): int
{
return (int)$value;
}
/**
* Truncate string with ellipsis
*
* @param string|null $string The string to truncate
* @param int $length Maximum length
* @param string $suffix Suffix to add if truncated
* @return string Truncated and escaped string
*/
public static function truncate(?string $string, int $length = 100, string $suffix = '...'): string
{
if ($string === null) {
return '';
}
if (mb_strlen($string, 'UTF-8') <= $length) {
return self::h($string);
}
return self::h(mb_substr($string, 0, $length, 'UTF-8')) . self::h($suffix);
}
/**
* Format a date safely
*
* @param string|int|null $date Date string, timestamp, or null
* @param string $format PHP date format
* @return string Formatted date
*/
public static function date($date, string $format = 'Y-m-d H:i:s'): string
{
if ($date === null || $date === '') {
return '';
}
if (is_numeric($date)) {
return date($format, (int)$date);
}
$timestamp = strtotime($date);
if ($timestamp === false) {
return '';
}
return date($format, $timestamp);
}
/**
* Check if a string is safe for use as a CSS class name
*
* @param string $class The class name to validate
* @return bool True if safe
*/
public static function isValidCssClass(string $class): bool
{
return preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $class) === 1;
}
/**
* Sanitize CSS class name(s)
*
* @param string|null $classes Space-separated class names
* @return string Sanitized class names
*/
public static function cssClass(?string $classes): string
{
if ($classes === null || $classes === '') {
return '';
}
$classList = explode(' ', $classes);
$validClasses = array_filter($classList, [self::class, 'isValidCssClass']);
return implode(' ', $validClasses);
}
}
/**
* Shorthand function for HTML escaping
*
* @param string|null $string The string to escape
* @return string Escaped string
*/
function h(?string $string): string
{
return OutputHelper::h($string);
}
+4 -1
View File
@@ -391,13 +391,16 @@ switch (true) {
LEFT JOIN ( LEFT JOIN (
SELECT user_id, MAX(created_at) as last_activity SELECT user_id, MAX(created_at) as last_activity
FROM audit_log FROM audit_log
WHERE DATE(created_at) BETWEEN ? AND ?
GROUP BY user_id GROUP BY user_id
) al ON u.user_id = al.user_id ) al ON u.user_id = al.user_id
ORDER BY tickets_created DESC, tickets_resolved DESC"; ORDER BY tickets_created DESC, tickets_resolved DESC";
$stmt = $conn->prepare($sql); $stmt = $conn->prepare($sql);
$stmt->bind_param( $stmt->bind_param(
'ssssssss', 'ssssssssss',
$dateRange['from'],
$dateRange['to'],
$dateRange['from'], $dateRange['from'],
$dateRange['to'], $dateRange['to'],
$dateRange['from'], $dateRange['from'],
+8 -7
View File
@@ -57,7 +57,7 @@ CREATE TABLE IF NOT EXISTS `bulk_operations` (
`operation_id` int(11) NOT NULL AUTO_INCREMENT, `operation_id` int(11) NOT NULL AUTO_INCREMENT,
`operation_type` varchar(50) NOT NULL, `operation_type` varchar(50) NOT NULL,
`ticket_ids` text NOT NULL, `ticket_ids` text NOT NULL,
`performed_by` int(11) NOT NULL, `performed_by` int(11) DEFAULT NULL,
`parameters` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`parameters`)), `parameters` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`parameters`)),
-- 32, not 20: 'completed_with_errors' is 21 chars (see 001_widen_bulk_operations_status.sql) -- 32, not 20: 'completed_with_errors' is 21 chars (see 001_widen_bulk_operations_status.sql)
`status` varchar(32) DEFAULT 'pending', `status` varchar(32) DEFAULT 'pending',
@@ -69,7 +69,7 @@ CREATE TABLE IF NOT EXISTS `bulk_operations` (
PRIMARY KEY (`operation_id`), PRIMARY KEY (`operation_id`),
KEY `idx_performed_by` (`performed_by`), KEY `idx_performed_by` (`performed_by`),
KEY `idx_created_at` (`created_at`), KEY `idx_created_at` (`created_at`),
CONSTRAINT `bulk_operations_ibfk_1` FOREIGN KEY (`performed_by`) REFERENCES `users` (`user_id`) CONSTRAINT `bulk_operations_ibfk_1` FOREIGN KEY (`performed_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ custom_field_definitions ============ -- ============ custom_field_definitions ============
@@ -155,7 +155,7 @@ CREATE TABLE IF NOT EXISTS `saved_filters` (
UNIQUE KEY `unique_user_filter_name` (`user_id`,`filter_name`), UNIQUE KEY `unique_user_filter_name` (`user_id`,`filter_name`),
KEY `idx_user_filters` (`user_id`,`is_default`), KEY `idx_user_filters` (`user_id`,`is_default`),
CONSTRAINT `saved_filters_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE CONSTRAINT `saved_filters_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ status_transitions ============ -- ============ status_transitions ============
CREATE TABLE IF NOT EXISTS `status_transitions` ( CREATE TABLE IF NOT EXISTS `status_transitions` (
@@ -185,7 +185,7 @@ CREATE TABLE IF NOT EXISTS `ticket_attachments` (
KEY `idx_attachments_ticket` (`ticket_id`), KEY `idx_attachments_ticket` (`ticket_id`),
KEY `idx_attachments_uploaded_by` (`uploaded_by`), KEY `idx_attachments_uploaded_by` (`uploaded_by`),
CONSTRAINT `ticket_attachments_ibfk_1` FOREIGN KEY (`uploaded_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL CONSTRAINT `ticket_attachments_ibfk_1` FOREIGN KEY (`uploaded_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ ticket_comments ============ -- ============ ticket_comments ============
CREATE TABLE IF NOT EXISTS `ticket_comments` ( CREATE TABLE IF NOT EXISTS `ticket_comments` (
@@ -238,16 +238,17 @@ CREATE TABLE IF NOT EXISTS `ticket_templates` (
PRIMARY KEY (`template_id`), PRIMARY KEY (`template_id`),
KEY `created_by` (`created_by`), KEY `created_by` (`created_by`),
KEY `idx_template_name` (`template_name`), KEY `idx_template_name` (`template_name`),
CONSTRAINT `ticket_templates_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) CONSTRAINT `ticket_templates_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ ticket_watchers ============ -- ============ ticket_watchers ============
CREATE TABLE IF NOT EXISTS `ticket_watchers` ( CREATE TABLE IF NOT EXISTS `ticket_watchers` (
`ticket_id` int(11) NOT NULL, `ticket_id` varchar(9) NOT NULL,
`user_id` int(11) NOT NULL, `user_id` int(11) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(), `created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`ticket_id`,`user_id`), PRIMARY KEY (`ticket_id`,`user_id`),
KEY `idx_watcher_user` (`user_id`) KEY `idx_watcher_user` (`user_id`),
CONSTRAINT `fk_watchers_ticket_id` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`ticket_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ tickets ============ -- ============ tickets ============
@@ -0,0 +1,30 @@
-- Fix collation inconsistency on saved_filters and ticket_attachments
--
-- README.md Developer Notes #12: "Database collation: Use
-- utf8mb4_general_ci (not unicode_ci) for new tables." These two tables
-- were created with utf8mb4_unicode_ci instead, inconsistent with every
-- other table in the schema. Mixed collations don't break anything by
-- themselves, but any future query joining/comparing these columns
-- against general_ci columns needs explicit COLLATE casts or hits
-- "Illegal mix of collations" errors.
--
-- Safe to re-run.
ALTER TABLE `saved_filters`
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
-- saved_filters.filter_criteria is pinned to utf8mb4_bin (for the
-- json_valid() CHECK constraint) — restore that after the table-wide
-- CONVERT TO above, which resets it to general_ci. MariaDB drops the
-- inline CHECK when the column is MODIFYed, so re-add it explicitly.
ALTER TABLE `saved_filters`
MODIFY COLUMN `filter_criteria` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL;
ALTER TABLE `saved_filters`
DROP CONSTRAINT IF EXISTS `saved_filters_filter_criteria_json`;
ALTER TABLE `saved_filters`
ADD CONSTRAINT `saved_filters_filter_criteria_json` CHECK (json_valid(`filter_criteria`));
ALTER TABLE `ticket_attachments`
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
+32
View File
@@ -0,0 +1,32 @@
-- Fix inconsistent FK ON DELETE behavior on bulk_operations.performed_by and
-- ticket_templates.created_by
--
-- Every other user-reference FK in the schema (tickets.created_by/updated_by/
-- assigned_to, ticket_attachments.uploaded_by, ticket_dependencies.created_by,
-- recurring_tickets.created_by/assigned_to, api_keys.created_by, etc.) uses
-- ON DELETE SET NULL. These two had no ON DELETE clause at all, which
-- defaults to RESTRICT — so deleting a user who ever ran a bulk operation or
-- created a template hard-fails at the DB level instead of nulling the
-- reference, breaking the pattern used everywhere else and potentially
-- blocking legitimate user offboarding/cleanup.
--
-- bulk_operations.performed_by is NOT NULL today; it must become nullable to
-- support SET NULL, matching how every other SET NULL column in the schema
-- is defined.
--
-- Safe to re-run.
ALTER TABLE `bulk_operations`
MODIFY COLUMN `performed_by` int(11) DEFAULT NULL;
ALTER TABLE `bulk_operations`
DROP FOREIGN KEY IF EXISTS `bulk_operations_ibfk_1`;
ALTER TABLE `bulk_operations`
ADD CONSTRAINT `bulk_operations_ibfk_1` FOREIGN KEY (`performed_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL;
ALTER TABLE `ticket_templates`
DROP FOREIGN KEY IF EXISTS `ticket_templates_ibfk_1`;
ALTER TABLE `ticket_templates`
ADD CONSTRAINT `ticket_templates_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL;
@@ -0,0 +1,28 @@
-- Fix ticket_watchers.ticket_id type mismatch and missing FK to tickets
--
-- ticket_watchers.ticket_id was int(11), while every other satellite table
-- (ticket_comments, ticket_attachments, ticket_dependencies,
-- custom_field_values) stores it as varchar(9)/varchar(10) matching
-- tickets.ticket_id. There was also no FK constraint at all, unlike every
-- other satellite table, so orphaned watcher rows could never be caught by
-- referential integrity. Ticket IDs are always 9-digit numeric strings
-- (see TicketModel::create's sprintf('%09d', ...)), so the int -> varchar(9)
-- conversion below is lossless for real data.
--
-- Safe to re-run.
-- Remove any watcher rows that no longer point at a real ticket (possible
-- today precisely because there was no FK to prevent it) before adding the
-- constraint, since orphans would make the ADD CONSTRAINT below fail.
DELETE tw FROM `ticket_watchers` tw
LEFT JOIN `tickets` t ON tw.`ticket_id` = t.`ticket_id`
WHERE t.`ticket_id` IS NULL;
ALTER TABLE `ticket_watchers`
MODIFY COLUMN `ticket_id` varchar(9) NOT NULL;
ALTER TABLE `ticket_watchers`
DROP FOREIGN KEY IF EXISTS `fk_watchers_ticket_id`;
ALTER TABLE `ticket_watchers`
ADD CONSTRAINT `fk_watchers_ticket_id` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`ticket_id`) ON DELETE CASCADE;
+68 -11
View File
@@ -46,6 +46,23 @@ if (!$conn->query($createTable)) {
exit(1); exit(1);
} }
// Tracks per-statement progress within a migration file. MySQL DDL statements
// (ALTER/CREATE TABLE, etc.) cause an implicit commit, so begin_transaction()/
// rollback() around a whole file can't actually undo DDL already executed
// earlier in that same file. This table lets a re-run after a partial failure
// resume from the statement after the last one that succeeded, instead of
// re-executing already-applied DDL and wedging on "already exists" errors.
$createProgressTable = "CREATE TABLE IF NOT EXISTS migration_progress (
filename VARCHAR(255) NOT NULL PRIMARY KEY,
last_statement_index INT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)";
if (!$conn->query($createProgressTable)) {
echo "Error: Could not create migration_progress table: " . $conn->error . "\n";
exit(1);
}
// Get list of completed migrations // Get list of completed migrations
$completed = []; $completed = [];
$result = $conn->query("SELECT filename FROM migrations ORDER BY id"); $result = $conn->query("SELECT filename FROM migrations ORDER BY id");
@@ -114,47 +131,87 @@ foreach ($pending as $file) {
continue; continue;
} }
// Execute migration - handle multiple statements // Execute migration statement-by-statement, tracking progress as we go.
$conn->begin_transaction(); // No begin_transaction()/rollback() here: DDL statements auto-commit in
// MySQL/MariaDB regardless, so a transaction wrapper around the whole
// file would only create the illusion of atomicity while giving no real
// protection. Instead, each statement commits immediately (autocommit),
// and its index is durably recorded so a later re-run can resume exactly
// where a previous run left off rather than re-executing already-applied
// DDL.
try { try {
// Split by semicolon but respect statements properly // Split by semicolon but respect statements properly
// Note: This doesn't handle semicolons in strings, but our migrations are simple // Note: This doesn't handle semicolons in strings, but our migrations are simple
$statements = array_filter( $statements = array_values(array_filter(
array_map('trim', explode(';', $sql)), array_map('trim', explode(';', $sql)),
function($stmt) { function($stmt) {
// Remove comments and check if there's actual SQL // Remove comments and check if there's actual SQL
$cleaned = preg_replace('/--.*$/m', '', $stmt); $cleaned = preg_replace('/--.*$/m', '', $stmt);
return !empty(trim($cleaned)); return !empty(trim($cleaned));
} }
); ));
$resumeFrom = 0;
$progressStmt = $conn->prepare(
"SELECT last_statement_index FROM migration_progress WHERE filename = ?"
);
$progressStmt->bind_param('s', $filename);
$progressStmt->execute();
$progressRow = $progressStmt->get_result()->fetch_assoc();
$progressStmt->close();
if ($progressRow) {
$resumeFrom = (int)$progressRow['last_statement_index'] + 1;
echo "\n Resuming from statement " . ($resumeFrom + 1) . " of " . count($statements)
. " after a previous partial failure... ";
}
foreach ($statements as $index => $statement) {
if ($index < $resumeFrom) {
continue;
}
foreach ($statements as $statement) {
if (!$conn->query($statement)) { if (!$conn->query($statement)) {
// Some "errors" are acceptable (like "index already exists") // Some "errors" are acceptable (like "index already exists")
$error = $conn->error; $error = $conn->error;
if (strpos($error, 'Duplicate key name') !== false || if (strpos($error, 'Duplicate key name') !== false ||
strpos($error, 'already exists') !== false) { strpos($error, 'already exists') !== false) {
// Index already exists, that's fine // Index already exists, that's fine
continue; } else {
throw new Exception($error);
} }
throw new Exception($error);
} }
// Record progress after every statement so a later run can
// resume from here even if a subsequent statement fails.
$upsert = $conn->prepare(
"INSERT INTO migration_progress (filename, last_statement_index) VALUES (?, ?)
ON DUPLICATE KEY UPDATE last_statement_index = VALUES(last_statement_index)"
);
$upsert->bind_param('si', $filename, $index);
$upsert->execute();
$upsert->close();
} }
// Record the migration // Record the migration as fully complete and clear its progress marker
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)"); $stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
$stmt->bind_param('s', $filename); $stmt->bind_param('s', $filename);
if (!$stmt->execute()) { if (!$stmt->execute()) {
throw new Exception("Could not record migration: " . $conn->error); throw new Exception("Could not record migration: " . $conn->error);
} }
$conn->commit(); $clearProgress = $conn->prepare("DELETE FROM migration_progress WHERE filename = ?");
$clearProgress->bind_param('s', $filename);
$clearProgress->execute();
$clearProgress->close();
echo "OK\n"; echo "OK\n";
$success++; $success++;
} catch (Exception $e) { } catch (Exception $e) {
$conn->rollback(); // Nothing to roll back: every statement up to the failure already
// committed (DDL implicitly, everything else via autocommit). The
// progress marker recorded above reflects exactly how far this file
// got, so the next run will resume right after the last success.
echo "FAILED (" . $e->getMessage() . ")\n"; echo "FAILED (" . $e->getMessage() . ")\n";
$failed++; $failed++;
} }
+10 -2
View File
@@ -16,7 +16,7 @@ class AttachmentModel
/** /**
* Get all attachments for a ticket * Get all attachments for a ticket
*/ */
public function getAttachments($ticketId) public function getAttachments($ticketId, int $limit = 0, int $offset = 0)
{ {
$sql = "SELECT a.*, u.username, u.display_name $sql = "SELECT a.*, u.username, u.display_name
FROM ticket_attachments a FROM ticket_attachments a
@@ -24,8 +24,16 @@ class AttachmentModel
WHERE a.ticket_id = ? WHERE a.ticket_id = ?
ORDER BY a.uploaded_at DESC"; ORDER BY a.uploaded_at DESC";
if ($limit > 0) {
$sql .= " LIMIT ? OFFSET ?";
}
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId); if ($limit > 0) {
$stmt->bind_param("sii", $ticketId, $limit, $offset);
} else {
$stmt->bind_param("s", $ticketId);
}
$stmt->execute(); $stmt->execute();
$result = $stmt->get_result(); $result = $stmt->get_result();
+17 -6
View File
@@ -309,17 +309,28 @@ class AuditLogModel
* @param int $daysToKeep Number of days of logs to keep * @param int $daysToKeep Number of days of logs to keep
* @return int Number of deleted records * @return int Number of deleted records
*/ */
public function deleteOldLogs($daysToKeep = 90) public function deleteOldLogs($daysToKeep = 90, $batchSize = 1000)
{ {
// Batched to bound how long each statement holds row locks — an
// unbounded single DELETE on a large backlog (e.g. the first run after
// enabling/changing retention, or after the cron silently missed runs)
// would otherwise contend with the frequent concurrent INSERTs the
// audit log receives from live traffic.
$stmt = $this->conn->prepare( $stmt = $this->conn->prepare(
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)" "DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY audit_id LIMIT ?"
); );
$stmt->bind_param("i", $daysToKeep); $stmt->bind_param("ii", $daysToKeep, $batchSize);
$stmt->execute();
$affectedRows = $stmt->affected_rows; $totalDeleted = 0;
do {
$stmt->execute();
$affected = $stmt->affected_rows;
$totalDeleted += $affected;
} while ($affected > 0);
$stmt->close(); $stmt->close();
return $affectedRows; return $totalDeleted;
} }
/** /**
+21
View File
@@ -172,6 +172,27 @@ class DependencyModel
} }
$checkStmt->close(); $checkStmt->close();
// Also check the semantic inverse: "A blocks B" and "B blocked_by A"
// describe the same relationship, so adding one from either ticket's
// page must be rejected as a duplicate of the other. relates_to is
// its own inverse (symmetric); duplicates has no defined inverse type.
$inverseTypes = ['blocks' => 'blocked_by', 'blocked_by' => 'blocks', 'relates_to' => 'relates_to'];
if (isset($inverseTypes[$type])) {
$inverseType = $inverseTypes[$type];
$checkInverseSql = "SELECT dependency_id FROM ticket_dependencies
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
$checkInverseStmt = $this->conn->prepare($checkInverseSql);
$checkInverseStmt->bind_param("sss", $dependsOnId, $ticketId, $inverseType);
$checkInverseStmt->execute();
$inverseResult = $checkInverseStmt->get_result();
if ($inverseResult->num_rows > 0) {
$checkInverseStmt->close();
return ['success' => false, 'error' => 'This relationship already exists'];
}
$checkInverseStmt->close();
}
// Check for circular dependency // Check for circular dependency
if ($this->wouldCreateCycle($ticketId, $dependsOnId, $type)) { if ($this->wouldCreateCycle($ticketId, $dependsOnId, $type)) {
return ['success' => false, 'error' => 'This would create a circular dependency']; return ['success' => false, 'error' => 'This would create a circular dependency'];
+27 -27
View File
@@ -189,30 +189,6 @@ class RecurringTicketModel
return $claimed; return $claimed;
} }
/**
* Update last run and calculate next run time
*/
public function updateAfterRun($recurringId)
{
$recurring = $this->getById($recurringId);
if (!$recurring) {
return false;
}
$nextRun = $this->calculateNextRunTime(
$recurring['schedule_type'],
$recurring['schedule_day'],
$recurring['schedule_time']
);
$sql = "UPDATE recurring_tickets SET last_run_at = NOW(), next_run_at = ? WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('si', $nextRun, $recurringId);
$success = $stmt->execute();
$stmt->close();
return $success;
}
/** /**
* Calculate the next run time based on schedule * Calculate the next run time based on schedule
*/ */
@@ -255,9 +231,33 @@ class RecurringTicketModel
*/ */
public function toggleActive($recurringId) public function toggleActive($recurringId)
{ {
$sql = "UPDATE recurring_tickets SET is_active = NOT is_active WHERE recurring_id = ?"; $recurring = $this->getById($recurringId);
$stmt = $this->conn->prepare($sql); if (!$recurring) {
$stmt->bind_param('i', $recurringId); return ['success' => false];
}
$newActive = $recurring['is_active'] ? 0 : 1;
if ($newActive) {
// Re-enabling: recompute next_run_at from now, as if the schedule
// were freshly created. Otherwise a schedule paused while
// next_run_at was still in the future, then re-enabled after that
// date has passed, would fire immediately on the next cron tick
// instead of waiting for its next natural occurrence.
$nextRun = $this->calculateNextRunTime(
$recurring['schedule_type'],
$recurring['schedule_day'],
$recurring['schedule_time']
);
$sql = "UPDATE recurring_tickets SET is_active = ?, next_run_at = ? WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('isi', $newActive, $nextRun, $recurringId);
} else {
$sql = "UPDATE recurring_tickets SET is_active = ? WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('ii', $newActive, $recurringId);
}
$success = $stmt->execute(); $success = $stmt->execute();
$stmt->close(); $stmt->close();
return ['success' => $success]; return ['success' => $success];
+4 -1
View File
@@ -726,7 +726,10 @@ class TicketModel
$groupConditions = []; $groupConditions = [];
foreach ($userGroups as $group) { foreach ($userGroups as $group) {
$groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))"; $groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))";
$params[] = $group; // Strip spaces from the bound value too, matching the REPLACE()
// applied to the column, so a group name like "IT Support" is
// normalized the same way on both sides of the comparison.
$params[] = str_replace(' ', '', $group);
$types .= 's'; $types .= 's';
} }
$conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))"; $conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))";
+18 -7
View File
@@ -98,19 +98,30 @@ class UserModel
$user['groups'] = $groups; $user['groups'] = $groups;
$user['is_admin'] = $isAdmin; $user['is_admin'] = $isAdmin;
} else { } else {
// Create new user // Create new user. Uses INSERT ... ON DUPLICATE KEY UPDATE (rather than
// a plain INSERT) so two concurrent first-visit requests for the same
// brand-new username can't race: the losing request updates the row the
// winner just created instead of throwing an uncaught duplicate-key
// exception (users.username has a UNIQUE KEY, and mysqli throws on
// constraint violation under PHP 8.1+'s default report mode).
$insertStmt = $this->conn->prepare( $insertStmt = $this->conn->prepare(
"INSERT INTO users (username, display_name, email, `groups`, is_admin, last_login) VALUES (?, ?, ?, ?, ?, NOW())" "INSERT INTO users (username, display_name, email, `groups`, is_admin, last_login)
VALUES (?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name),
email = VALUES(email),
`groups` = VALUES(groups),
is_admin = VALUES(is_admin),
last_login = NOW()"
); );
$insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin); $insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin);
$insertStmt->execute(); $insertStmt->execute();
$userId = $this->conn->insert_id;
$insertStmt->close(); $insertStmt->close();
// Get the newly created user // Re-fetch by username — works whether this request won the insert or
$stmt = $this->conn->prepare("SELECT * FROM users WHERE user_id = ?"); // lost the race and only updated the winner's row.
$stmt->bind_param("i", $userId); $stmt = $this->conn->prepare("SELECT * FROM users WHERE username = ?");
$stmt->bind_param("s", $username);
$stmt->execute(); $stmt->execute();
$result = $stmt->get_result(); $result = $stmt->get_result();
$user = $result->fetch_assoc(); $user = $result->fetch_assoc();
+47
View File
@@ -10,9 +10,30 @@
* Usage: php scripts/check_requirements.php * Usage: php scripts/check_requirements.php
*/ */
/**
* Parse a php.ini size value (e.g. "128M", "1G", "-1") into bytes.
* Returns -1 for unlimited.
*/
function parseIniBytes(string $val): int
{
$val = trim($val);
if ($val === '' || $val === '-1') {
return -1;
}
$unit = strtolower(substr($val, -1));
$num = (int)$val;
return match ($unit) {
'g' => $num * 1024 * 1024 * 1024,
'm' => $num * 1024 * 1024,
'k' => $num * 1024,
default => $num,
};
}
$req = require __DIR__ . '/../config/requirements.php'; $req = require __DIR__ . '/../config/requirements.php';
$errors = []; $errors = [];
$warnings = [];
// PHP version // PHP version
$minPhp = $req['min_php_version']; $minPhp = $req['min_php_version'];
@@ -27,6 +48,28 @@ foreach ($req['required_extensions'] as $ext) {
} }
} }
// memory_limit / max_execution_time sanity checks (warnings, not hard
// failures — see config/requirements.php for why these matter).
$memLimitIni = ini_get('memory_limit');
$memLimitBytes = parseIniBytes($memLimitIni);
$minMemBytes = $req['min_memory_limit_mb'] * 1024 * 1024;
if ($memLimitBytes !== -1 && $memLimitBytes < $minMemBytes) {
$warnings[] = sprintf(
'memory_limit is %s, below the recommended minimum %dM',
$memLimitIni,
$req['min_memory_limit_mb']
);
}
$maxExecTime = (int)ini_get('max_execution_time');
if ($maxExecTime !== 0 && $maxExecTime < $req['min_max_execution_time']) {
$warnings[] = sprintf(
'max_execution_time is %ds, below the recommended minimum %ds',
$maxExecTime,
$req['min_max_execution_time']
);
}
if (!empty($errors)) { if (!empty($errors)) {
fwrite(STDERR, "Requirement check FAILED:\n"); fwrite(STDERR, "Requirement check FAILED:\n");
foreach ($errors as $err) { foreach ($errors as $err) {
@@ -35,6 +78,10 @@ if (!empty($errors)) {
exit(1); exit(1);
} }
foreach ($warnings as $warn) {
fwrite(STDERR, "Requirement check WARNING: " . $warn . "\n");
}
printf( printf(
"Requirement check passed: PHP %s (>= %s); extensions: %s\n", "Requirement check passed: PHP %s (>= %s); extensions: %s\n",
PHP_VERSION, PHP_VERSION,
+15 -4
View File
@@ -344,12 +344,23 @@ include __DIR__ . '/layout_header.php';
var existingTitle = (document.getElementById('title').value || '').trim(); var existingTitle = (document.getElementById('title').value || '').trim();
var existingDesc = (document.getElementById('description').value || '').trim(); var existingDesc = (document.getElementById('description').value || '').trim();
if (existingTitle || existingDesc) { if (existingTitle || existingDesc) {
if (!confirm('Applying this template will overwrite your current title and description. Continue?')) { showConfirmModal(
document.getElementById('templateSelect').value = ''; 'Overwrite content?',
return; 'Applying this template will overwrite your current title and description. Continue?',
} 'warning',
applyTemplate,
function () { document.getElementById('templateSelect').value = ''; }
);
return;
} }
applyTemplate();
}
function applyTemplate() {
var tplId = document.getElementById('templateSelect').value;
if (!tplId) return;
lt.api.get('/api/get_template.php?template_id=' + encodeURIComponent(tplId)) lt.api.get('/api/get_template.php?template_id=' + encodeURIComponent(tplId))
.then(function (data) { .then(function (data) {
if (!data.success || !data.template) { if (!data.success || !data.template) {
+82 -9
View File
@@ -120,7 +120,6 @@ include __DIR__ . '/layout_header.php';
?> ?>
<div class="lt-stat-card stat-open" role="button" tabindex="0" <div class="lt-stat-card stat-open" role="button" tabindex="0"
data-filter-key="status" data-filter-val="Open,Pending,In Progress"
title="Click to filter by active tickets" aria-label="Open tickets"> title="Click to filter by active tickets" aria-label="Open tickets">
<div class="lt-stat-icon">[ # ]</div> <div class="lt-stat-icon">[ # ]</div>
<div class="lt-stat-info"> <div class="lt-stat-info">
@@ -133,7 +132,6 @@ include __DIR__ . '/layout_header.php';
</div> </div>
<div class="lt-stat-card stat-critical" role="button" tabindex="0" <div class="lt-stat-card stat-critical" role="button" tabindex="0"
data-filter-key="priority" data-filter-val="1"
title="Click to filter critical (P1) tickets" aria-label="Critical P1 tickets"> title="Click to filter critical (P1) tickets" aria-label="Critical P1 tickets">
<div class="lt-stat-icon lt-text-danger">[ ! ]</div> <div class="lt-stat-icon lt-text-danger">[ ! ]</div>
<div class="lt-stat-info"> <div class="lt-stat-info">
@@ -146,7 +144,6 @@ include __DIR__ . '/layout_header.php';
</div> </div>
<div class="lt-stat-card stat-unassigned" role="button" tabindex="0" <div class="lt-stat-card stat-unassigned" role="button" tabindex="0"
data-filter-key="assigned_to" data-filter-val="unassigned"
title="Click to filter unassigned tickets" aria-label="Unassigned tickets"> title="Click to filter unassigned tickets" aria-label="Unassigned tickets">
<div class="lt-stat-icon lt-text-amber">[ @ ]</div> <div class="lt-stat-icon lt-text-amber">[ @ ]</div>
<div class="lt-stat-info"> <div class="lt-stat-info">
@@ -171,7 +168,6 @@ include __DIR__ . '/layout_header.php';
</div> </div>
<div class="lt-stat-card stat-resolved" role="button" tabindex="0" <div class="lt-stat-card stat-resolved" role="button" tabindex="0"
data-filter-key="status" data-filter-val="Closed"
title="Click to filter closed tickets" aria-label="Closed tickets today"> title="Click to filter closed tickets" aria-label="Closed tickets today">
<div class="lt-stat-icon lt-text-muted">[ OK ]</div> <div class="lt-stat-icon lt-text-muted">[ OK ]</div>
<div class="lt-stat-info"> <div class="lt-stat-info">
@@ -277,9 +273,66 @@ include __DIR__ . '/layout_header.php';
array_values($stats['by_category'] ?? []) array_values($stats['by_category'] ?? [])
))) ?>; ))) ?>;
// ── Click-to-filter ────────────────────────────────────────────────────────
// Charts navigate to the same URL filters the stat cards use.
//
// The status the click filters on has to be explicit rather than left to the
// default: with no `status` param the controller falls back to the viewer's
// default_status_filters preference, which can be anything, so the resulting
// list would not necessarily match what the chart counted. StatsModel builds
// by_priority and by_category with `status != 'Closed'`, while by_status spans
// every status — so only the priority and category charts pin the open set.
function openStatuses() {
var all = window.TICKET_STATUSES || ['Open', 'Pending', 'In Progress', 'Closed'];
return all.filter(function(s) { return s !== 'Closed'; }).join(',');
}
function gotoFilter(params) {
var qs = new URLSearchParams(window.location.search);
Object.keys(params).forEach(function(k) {
if (params[k] !== null && params[k] !== undefined && params[k] !== '') qs.set(k, params[k]);
else qs.delete(k);
});
window.location.href = '/?' + qs.toString();
}
// Each chart maps a clicked label to a filter. Returns null when the label
// can't be mapped, so the click is simply ignored.
var CHART_FILTERS = {
chartPriority: function(label) {
var m = /^P(\d+)$/.exec(label);
return m ? { priority: m[1], status: openStatuses() } : null;
},
chartStatus: function(label) {
return label ? { status: label } : null;
},
chartCategory: function(label) {
return label ? { category: label, status: openStatuses() } : null;
}
};
function filterOnClick(canvasId) {
return function(evt, elements, chart) {
if (!elements || !elements.length) return;
var label = chart.data.labels[elements[0].index];
var mapper = CHART_FILTERS[canvasId];
var params = mapper && mapper(label);
if (params) gotoFilter(params);
};
}
// Pointer cursor over clickable segments so the affordance is visible.
function filterOnHover(evt, elements) {
if (evt && evt.native && evt.native.target) {
evt.native.target.style.cursor = (elements && elements.length) ? 'pointer' : 'default';
}
}
function makeDonut(canvasId, data, colorMap) { function makeDonut(canvasId, data, colorMap) {
var ctx = document.getElementById(canvasId); var ctx = document.getElementById(canvasId);
if (!ctx || !data.length) return; if (!ctx) return;
if (!data.length) { showChartEmptyState(ctx); return; }
ctx.title = 'Click a segment to filter the ticket list';
return new Chart(ctx, { return new Chart(ctx, {
type: 'doughnut', type: 'doughnut',
data: { data: {
@@ -295,21 +348,37 @@ include __DIR__ . '/layout_header.php';
}, },
options: { options: {
responsive: true, maintainAspectRatio: false, responsive: true, maintainAspectRatio: false,
onClick: filterOnClick(canvasId),
onHover: filterOnHover,
plugins: { plugins: {
legend: { legend: {
position: 'bottom', position: 'bottom',
labels: { color: '#8fa3b1', font: { family: 'monospace', size: 10 }, padding: 8, boxWidth: 10 } labels: { color: '#8fa3b1', font: { family: 'monospace', size: 10 }, padding: 8, boxWidth: 10 }
}, },
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.label + ': ' + ctx.parsed; } } } tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.label + ': ' + ctx.parsed + ' — click to filter'; } } }
}, },
cutout: '68%' cutout: '68%'
} }
}); });
} }
function showChartEmptyState(canvas) {
canvas.style.display = 'none';
var wrap = canvas.parentElement;
if (wrap && !wrap.querySelector('.lt-chart-empty')) {
var msg = document.createElement('div');
msg.className = 'lt-chart-empty';
msg.style.cssText = 'display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:0.75rem';
msg.textContent = 'No data for current filters';
wrap.appendChild(msg);
}
}
function makeBar(canvasId, data) { function makeBar(canvasId, data) {
var ctx = document.getElementById(canvasId); var ctx = document.getElementById(canvasId);
if (!ctx || !data.length) return; if (!ctx) return;
if (!data.length) { showChartEmptyState(ctx); return; }
ctx.title = 'Click a bar to filter the ticket list';
return new Chart(ctx, { return new Chart(ctx, {
type: 'bar', type: 'bar',
data: { data: {
@@ -323,7 +392,12 @@ include __DIR__ . '/layout_header.php';
}, },
options: { options: {
indexAxis: 'y', responsive: true, maintainAspectRatio: false, indexAxis: 'y', responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } }, onClick: filterOnClick(canvasId),
onHover: filterOnHover,
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.parsed.x + ' — click to filter'; } } }
},
scales: { scales: {
x: { ticks: { color: '#8fa3b1', font: { size: 10 } }, grid: { color: 'rgba(0,255,65,0.06)' } }, x: { ticks: { color: '#8fa3b1', font: { size: 10 } }, grid: { color: 'rgba(0,255,65,0.06)' } },
y: { ticks: { color: '#8fa3b1', font: { family: 'monospace', size: 10 } }, grid: { display: false } } y: { ticks: { color: '#8fa3b1', font: { family: 'monospace', size: 10 } }, grid: { display: false } }
@@ -1162,7 +1236,6 @@ window.TICKET_STATUSES = <?= json_encode($GLOBALS['config']['TICKET_STATUSES'])
if (window.lt) { if (window.lt) {
lt.keys.initDefaults(); lt.keys.initDefaults();
lt.tableNav.init('tickets-table'); lt.tableNav.init('tickets-table');
lt.statsFilter.init();
} }
// Saved filter pills — load on page init // Saved filter pills — load on page init
+14 -96
View File
@@ -114,6 +114,7 @@ $json_priority = json_encode($ticket['priority'], JSON_HEX_TAG);
$json_category = json_encode($ticket['category'], JSON_HEX_TAG); $json_category = json_encode($ticket['category'], JSON_HEX_TAG);
$json_type = json_encode($ticket['type'], JSON_HEX_TAG); $json_type = json_encode($ticket['type'], JSON_HEX_TAG);
$json_updated_at = json_encode($ticket['updated_at'], JSON_HEX_TAG); $json_updated_at = json_encode($ticket['updated_at'], JSON_HEX_TAG);
$json_created_at_ts = json_encode((int)strtotime($ticket['created_at']), JSON_HEX_TAG);
$json_total_comments = json_encode((int)$totalComments, JSON_HEX_TAG); $json_total_comments = json_encode((int)$totalComments, JSON_HEX_TAG);
$json_comment_page = json_encode((int)$commentPageSize, JSON_HEX_TAG); $json_comment_page = json_encode((int)$commentPageSize, JSON_HEX_TAG);
$json_current_uid = json_encode((int)($currentUser['user_id'] ?? 0), JSON_HEX_TAG); $json_current_uid = json_encode((int)($currentUser['user_id'] ?? 0), JSON_HEX_TAG);
@@ -127,6 +128,7 @@ window.ticketData = {
category: {$json_category}, category: {$json_category},
type: {$json_type}, type: {$json_type},
updated_at: {$json_updated_at}, updated_at: {$json_updated_at},
created_at_ts: {$json_created_at_ts},
totalComments: {$json_total_comments}, totalComments: {$json_total_comments},
commentOffset: {$json_comment_page}, commentOffset: {$json_comment_page},
commentPageSize:{$json_comment_page}, commentPageSize:{$json_comment_page},
@@ -209,95 +211,17 @@ include __DIR__ . '/layout_header.php';
</div> </div>
</div> </div>
<?php if ($priorityNum <= 2 && $ticket['status'] !== 'Closed') : ?> <?php // SLA banner (P1/P2, non-Closed tickets) is rendered and kept live by
<?php // renderSlaBanner() in ticket.js, so it can also rebuild/tear itself
$slaTargetHours = match ($priorityNum) { // down when priority changes client-side without a page reload. ?>
1 => 8, 2 => 24, default => 72 <div id="priorityAlertBannerAnchor"></div>
};
$elapsedSeconds = time() - strtotime($ticket['created_at']);
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
$slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600);
$slaClass = $priorityNum === 1 ? 'lt-sla-p1' : 'lt-sla-p2';
$slaIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
$slaLabel = $priorityNum === 1 ? 'P1 Critical' : 'P2 High';
$slaId = 'sla-' . htmlspecialchars($ticket['ticket_id'], ENT_QUOTES, 'UTF-8');
?>
<!-- SLA banner — P1/P2 only, dismissible per session -->
<div class="<?= $slaClass ?>" id="priorityAlertBanner" role="alert" aria-live="polite"
data-sla-id="<?= $slaId ?>"
data-created-at="<?= (int)strtotime($ticket['created_at']) ?>"
data-sla-hours="<?= $slaTargetHours ?>"
style="margin-bottom:0.75rem">
<span class="lt-sla-icon" aria-hidden="true"><?= $slaIcon ?></span>
<div class="lt-sla-info">
<div class="lt-sla-title">
<?= $slaLabel ?> — SLA: <span id="slaElapsedTimer"></span> elapsed of <?= $slaTargetHours ?>h limit
<?php if ($slaBreached) : ?>
&nbsp;<span class="lt-text-danger" id="slaBreachLabel">BREACHED</span>
<?php endif ?>
</div>
<div class="lt-sla-bar" aria-label="SLA progress <?= $slaPct ?>%" id="slaProgress">
<div class="lt-sla-fill" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div>
</div>
</div>
<?php if (!$slaBreached) : ?>
<div class="lt-sla-meta" id="slaCountdownTimer"></div>
<?php else : ?>
<div class="lt-sla-meta lt-text-danger" id="slaCountdownTimer">+<span id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</span> over</div>
<?php endif ?>
<button type="button" class="lt-sla-dismiss" aria-label="Dismiss">&#x2715;</button>
</div>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>"> <script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
(function(){ document.addEventListener('DOMContentLoaded', function() {
var banner = document.getElementById('priorityAlertBanner'); if (typeof renderSlaBanner === 'function') {
var id = banner.dataset.slaId; renderSlaBanner(window.ticketData.priority);
try { if (id && sessionStorage.getItem('lt_sla_dismissed_' + id)) banner.hidden = true; } catch(e) {}
banner.querySelector('.lt-sla-dismiss').addEventListener('click', function() {
banner.hidden = true;
try { if (id) sessionStorage.setItem('lt_sla_dismissed_' + id, '1'); } catch(e) {}
});
document.addEventListener('DOMContentLoaded', function() {
if (banner.hidden) return;
var createdAt = parseInt(banner.dataset.createdAt, 10) * 1000;
var slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
var deadline = new Date(createdAt + slaMs);
var elapsedEl = document.getElementById('slaElapsedTimer');
var countdownEl = document.getElementById('slaCountdownTimer');
var overrunEl = document.getElementById('slaOverrunTimer');
var fillBar = document.getElementById('slaProgressBar');
var progressWrap = document.getElementById('slaProgress');
function fmtHMS(ms) {
var s = Math.floor(Math.abs(ms) / 1000);
var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
return [h, m, ss].map(function(n){ return String(n).padStart(2,'0'); }).join(':');
} }
});
function tick() {
var now = Date.now();
var elapsed = now - createdAt;
var remaining = deadline - now;
var pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
if (fillBar) fillBar.style.width = pct + '%';
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
if (remaining > 0) {
if (countdownEl) countdownEl.textContent = fmtHMS(remaining) + ' remaining';
} else {
if (overrunEl) overrunEl.textContent = fmtHMS(-remaining);
}
}
tick();
setInterval(tick, 1000);
});
})();
</script> </script>
<?php endif ?>
<!-- ═══════════════════════════════════════════════════════════ <!-- ═══════════════════════════════════════════════════════════
TICKET DETAIL FRAME TICKET DETAIL FRAME
@@ -1006,9 +930,7 @@ document.addEventListener('DOMContentLoaded', function () {
shown.forEach(function (w) { shown.forEach(function (w) {
var words = (w.display_name || '').trim().split(/\s+/).filter(Boolean); var words = (w.display_name || '').trim().split(/\s+/).filter(Boolean);
var initials = words.slice(0, 2).map(function (x) { return x[0].toUpperCase(); }).join(''); var initials = words.slice(0, 2).map(function (x) { return x[0].toUpperCase(); }).join('');
var hash = 0; var color = avatarColors[crc32(w.display_name || '') % 4];
for (var i = 0; i < (w.display_name || '').length; i++) hash = ((hash << 5) - hash + (w.display_name || '').charCodeAt(i)) | 0;
var color = avatarColors[Math.abs(hash) % 4];
html += '<div class="lt-avatar lt-avatar--xs ' + color + '" title="' + lt.escHtml(w.display_name) + '" aria-label="' + lt.escHtml(w.display_name) + '">' + html += '<div class="lt-avatar lt-avatar--xs ' + color + '" title="' + lt.escHtml(w.display_name) + '" aria-label="' + lt.escHtml(w.display_name) + '">' +
'<img src="/api/user_avatar.php?user_id=' + w.user_id + '" alt="" class="lt-avatar-img">' + '<img src="/api/user_avatar.php?user_id=' + w.user_id + '" alt="" class="lt-avatar-img">' +
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' + '<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
@@ -1219,7 +1141,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (typeof parseMarkdown === 'function') { if (typeof parseMarkdown === 'function') {
list.querySelectorAll('.comment-text[data-markdown]').forEach(function (el) { list.querySelectorAll('.comment-text[data-markdown]').forEach(function (el) {
if (!el.dataset.rendered) { if (!el.dataset.rendered) {
el.innerHTML = parseMarkdown(el.textContent); el.innerHTML = parseMarkdown(el.textContent.trim());
el.dataset.rendered = '1'; el.dataset.rendered = '1';
} }
}); });
@@ -1252,13 +1174,9 @@ document.addEventListener('DOMContentLoaded', function () {
var words = displayName.trim().split(/\s+/).filter(Boolean); var words = displayName.trim().split(/\s+/).filter(Boolean);
var initials = words.slice(0, 2).map(function (w) { return w[0].toUpperCase(); }).join(''); var initials = words.slice(0, 2).map(function (w) { return w[0].toUpperCase(); }).join('');
// Avatar color (same modulo logic as PHP: crc32 mod 4) // Avatar color (real crc32, matching PHP's crc32 % 4 exactly)
var avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', '']; var avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
var hash = 0; var avatarColor = avatarColors[crc32(displayName) % 4];
for (var i = 0; i < displayName.length; i++) {
hash = ((hash << 5) - hash + displayName.charCodeAt(i)) | 0;
}
var avatarColor = avatarColors[Math.abs(hash) % 4];
// Format date // Format date
var dateStr = c.created_at || ''; var dateStr = c.created_at || '';
+30 -4
View File
@@ -235,11 +235,12 @@
} }
function loadNotifications() { function loadNotifications() {
fetch('/api/notifications.php', { credentials: 'same-origin' }) return fetch('/api/notifications.php', { credentials: 'same-origin' })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(renderNotifications) .then(function(data) { renderNotifications(data); return true; })
.catch(function() { .catch(function() {
list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>'; list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>';
return false;
}); });
} }
@@ -261,9 +262,34 @@
document.addEventListener('click', function(e) { if (_open && wrapEl && !wrapEl.contains(e.target)) closePanel(); }); document.addEventListener('click', function(e) { if (_open && wrapEl && !wrapEl.contains(e.target)) closePanel(); });
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && _open) closePanel(); }); document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && _open) closePanel(); });
// Initial badge count + poll every 60s // Poll every 60s while the tab is visible, backing off (up to 5 min) on
// repeated failures, and resuming immediately when the tab regains focus.
var POLL_INTERVAL = 60000;
var MAX_POLL_INTERVAL = 300000;
var _pollTimer = null;
var _failCount = 0;
function scheduleNextPoll(delay) {
clearTimeout(_pollTimer);
_pollTimer = setTimeout(pollNotifications, delay);
}
function pollNotifications() {
if (document.hidden) return;
loadNotifications().then(function(ok) {
_failCount = ok ? 0 : _failCount + 1;
var delay = ok ? POLL_INTERVAL : Math.min(POLL_INTERVAL * Math.pow(2, _failCount), MAX_POLL_INTERVAL);
scheduleNextPoll(delay);
});
}
document.addEventListener('visibilitychange', function() {
if (!document.hidden) pollNotifications();
});
// Initial badge count, then start the poll cycle
loadNotifications(); loadNotifications();
setInterval(loadNotifications, 60000); scheduleNextPoll(POLL_INTERVAL);
})(); })();
<?php endif ?> <?php endif ?>