Compare commits

..
Author SHA1 Message Date
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
jared 4fd2c7ce7d Merge #20 modal dismissal fix into main
Lint / PHP (phpcs PSR-12) (push) Successful in 18s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 39s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m32s
Lint / Deploy (push) Successful in 3s
2026-08-07 23:07:14 -04:00
jared 2ff7345a73 Dismissing the required-comment modal no longer looks like a close (#20)
Lint / JS (eslint) (push) Successful in 15s
Lint / PHP requirements (version + extensions) (push) Successful in 41s
Security / PHP Security (semgrep) (push) Successful in 1m8s
Lint / PHP (phpcs PSR-12) (push) Successful in 18s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Successful in 2s
A modal can be dismissed four ways: the ✕ button, Cancel, a backdrop click, or
Escape. base.js handles the last two globally (a document click handler and
registerKey('escape', closeAllModals)), so the status-change modal — which wired
only the two buttons — never learned it had been dismissed. The status dropdown
kept displaying the new status even though update_ticket.php was never called,
so the ticket looked closed with no comment until a reload showed it still open.

The same gap left every dynamically-inserted modal in the DOM when dismissed
that way, so the next open inserted a duplicate id that shadowed the live one.

- base.js closeModal now dispatches a bubbling lt:modalclose event (synced to
  web_template as bbec859), and _statusCommentModal treats it as "no comment".
- ticket.js reverts the dropdown on any dismissal, guarded against the re-entry
  its own lt.modal.close() would otherwise cause.
- dashboard.js gains openModalWithDismiss() so all seven dynamic modals plus the
  generic prompt modal tear down however they are dismissed.

Verified in headless chromium against all four dismissal routes plus a
confirm-with-comment control: 22/22. Against the pre-fix files the same test
fails 6 assertions — backdrop and Escape leave the dropdown on "Closed *" with
an orphaned overlay — so it reproduces the reported behaviour exactly.
2026-08-07 23:07:06 -04:00
jared 1de04d4908 Clear the markdown live preview after posting a comment
Setting the textarea's .value programmatically does not fire an 'input' event,
so updatePreview() never ran and the preview kept showing the just-posted
comment's rendered markdown underneath an empty composer.

(This change was already present in the working tree at the start of the
session; committing it on its own rather than folding it into an unrelated fix.)
2026-08-07 23:06:36 -04:00
jared 153f9a7cef Merge #23 light-mode ticket preview fix into main
Lint / PHP (phpcs PSR-12) (push) Successful in 1m9s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 21s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m54s
Lint / Deploy (push) Successful in 6s
2026-08-07 22:56:41 -04:00
jared 0a7201d754 Light mode: ticket-ID hover preview follows the theme (#23)
Lint / PHP (phpcs PSR-12) (push) Successful in 17s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 22s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m15s
Lint / Deploy (push) Successful in 2s
.ticket-preview-popup used var(--lt-surface), which is not defined anywhere, so
the background always fell through to the hardcoded #0a0e14. In light mode that
left a near-black panel — and since the rule set no `color`, the inherited
near-black body text was effectively invisible on it. The border was hardcoded
neon green and the shadow a heavy rgba(0,0,0,0.5).

Now uses --bg-card / --text-primary / --accent-green-border / --shadow-color,
and .preview-id uses --accent-cyan instead of the undefined --lt-cyan.

base.css gains the two tokens the light theme was missing (--accent-green-border
and --shadow-color), synced from web_template 0d633bd.

Verified with computed styles in headless chromium: light body-text contrast on
the panel goes from invisible to 17.7:1, dark stays at 13.2:1, and the ID accent
clears 3:1 in both themes.
2026-08-07 22:54:51 -04:00
jared 12ffd217bb Merge #19 light-mode status dropdown fix into main
Lint / PHP (phpcs PSR-12) (push) Successful in 29s
Lint / JS (eslint) (push) Successful in 17s
Lint / PHP requirements (version + extensions) (push) Successful in 40s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m14s
Lint / Deploy (push) Successful in 3s
2026-08-07 22:51:22 -04:00
jared a5b0655623 Light mode: status dropdown no longer renders dark (#19)
Lint / PHP (phpcs PSR-12) (push) Successful in 23s
Lint / JS (eslint) (push) Successful in 11s
Lint / PHP requirements (version + extensions) (push) Successful in 22s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m11s
Lint / Deploy (push) Successful in 2s
Two separate causes, both light-mode-only:

1. base.css `.lt-select` sets `color-scheme: dark` on the element itself, which
   outranks the `color-scheme: light` the light theme sets on <html>, so the
   native dropdown popup kept dark chrome. The option list is also hardcoded
   #0d1117/#c9d1d9 with no light override. Fixed with light overrides for both
   (synced from web_template, where the same fix landed as 378a8cd).

2. ticket.css coloured the status select with var(--lt-success), --lt-amber,
   --lt-cyan and --lt-danger — none of which are defined anywhere in the
   project, so all four always fell through to hardcoded neon fallbacks. Now
   uses the --accent-* tokens, which carry the same hues and are redefined for
   light mode. The selectors also lead with .lt-select: at two classes they lost
   to base.css's `html[data-theme="light"] .lt-select` (0,2,1) and every status
   was repainted near-black in light mode.

Verified with computed styles in headless chromium — all four statuses in both
themes (8/8), plus the popup colour-scheme and option colours.
2026-08-07 22:51:16 -04:00
jared fa5f347c08 Merge #21 workflow enforcement for bulk operations into main
Lint / PHP (phpcs PSR-12) (push) Successful in 31s
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP requirements (version + extensions) (push) Successful in 40s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m13s
Lint / Deploy (push) Successful in 3s
2026-08-07 22:45:34 -04:00
jared 1d03800ab2 Widen bulk_operations.status so partial bulk results can be recorded (#21)
Lint / PHP (phpcs PSR-12) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 22s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m8s
Lint / Deploy (push) Successful in 3s
Found while verifying #21 against the live schema: the model writes
'completed_with_errors' (21 chars) when a bulk operation finishes with
per-ticket failures, but bulk_operations.status was varchar(20), so the
write failed with "Data too long for column 'status'".

This was latent — bulk status changes previously forced every transition
through, so failed was always 0. Now that they honour the Workflow
Designer, a partially-skipped batch is a normal outcome and hits it.

- migrations/001 widens the column to varchar(32) (idempotent).
- The baseline is updated to match, for fresh installs.
- The bookkeeping UPDATE is wrapped in a try/catch: it runs after the
  ticket changes are committed, so an instance deployed ahead of its
  migrations must not turn a completed operation into an error response.

Verified against the live database with a disposable-ticket harness:
comment-required rejection changes nothing, undefined transitions are
refused per ticket with a reason, allowed transitions still work, mixed
batches apply the valid half, and an already-Closed ticket is a no-op.
2026-08-07 22:40:48 -04:00
jared 9d982ab73f Bulk status/close: enforce Workflow Designer rules (#21)
Lint / PHP (phpcs PSR-12) (push) Successful in 23s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 30s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m15s
Lint / Deploy (push) Successful in 2s
Bulk status changes previously bypassed the workflow entirely — the model
carried an explicit "admin-only escape hatch" note — so bulk edit could
drive tickets through transitions the designer forbids and skip comments
the designer requires.

BulkOperationsModel now applies the same rules as the single-ticket path:

- Transitions absent from status_transitions are refused per ticket and
  reported with a reason, instead of being forced through.
- requires_comment is checked up front across the whole selection, so a
  batch is rejected before any ticket is mutated rather than half-applied.
- The reason is persisted as a comment on each ticket changed, matching
  what a single-ticket close records.
- Tickets already in the target status are a no-op success, not a failure.

requires_admin needs no extra check: api/bulk_operation.php already gates
the endpoint on admin.

Client: both bulk modals now collect a reason, the close path gets a real
modal instead of a bare confirm, and per-ticket skip reasons surface in
the result toast instead of a bare failure count.
2026-08-07 22:35:36 -04:00
jared f57b472211 Merge pull request 'Bearer API extension: list/read/comment/close + key scopes' (#26) from development into main
Lint / PHP (phpcs PSR-12) (push) Successful in 25s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 18s
Security / PHP Security (semgrep) (push) Successful in 1m2s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
2026-07-15 19:19:24 -04:00
jaredandClaude Opus 4.8 d81fdf4104 Docs: document the Bearer API (endpoints, scopes) in README + admin page
Lint / PHP (phpcs PSR-12) (push) Successful in 24s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 28s
Security / PHP Security (semgrep) (push) Successful in 1m23s
Lint / Deploy (push) Successful in 2s
Lint / Notify on failure (push) Has been skipped
Lint / PHP (phpcs PSR-12) (pull_request) Successful in 19s
Lint / JS (eslint) (pull_request) Successful in 6s
Lint / PHP requirements (version + extensions) (pull_request) Successful in 23s
Security / PHP Security (semgrep) (pull_request) Successful in 2m36s
Lint / Deploy (pull_request) Has been skipped
Lint / Notify on failure (pull_request) Has been skipped
- README: Bearer API table (list/read/comment/status), scope explanation,
  and the new endpoints in the API Endpoints table.
- /admin/api-keys API Usage section: scopes note + copy-paste cURL examples
  for create, list/triage, read-one, comment, and close (uses APP_DOMAIN).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:16:42 -04:00
jaredandClaude Opus 4.8 d46f8ffd77 Add Bearer API: list/read tickets, post comments, change status
Lint / PHP (phpcs PSR-12) (push) Successful in 41s
Lint / JS (eslint) (push) Successful in 11s
Lint / PHP requirements (version + extensions) (push) Successful in 44s
Security / PHP Security (semgrep) (push) Successful in 2m47s
Lint / Deploy (push) Successful in 2s
Lint / Notify on failure (push) Has been skipped
Extends the Bearer-key API beyond create-only (all rate-limited, scope-
enforced, per-key-label attribution):
- GET /api/tickets_api.php: triage the queue (status/priority/host title
  match + pagination) or read one ticket + its comments. read scope.
- POST /api/ticket_comment_api.php: post a comment as the key (user_name =
  key name, linked to the key owner). read_write scope.
- POST /api/ticket_status_api.php: change/close status with workflow
  validation + requires_comment; posts the close reason in the same call,
  fires the Matrix status notification, invalidates stats. read_write scope.

Reuses TicketModel/CommentModel/WorkflowModel/NotificationHelper; a read
key cannot mutate. Reachability requires the reverse-proxy Authelia bypass
(handled separately).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:40:52 -04:00
jaredandClaude Opus 4.8 5cf5aa9591 API keys: add read/read_write scopes + admin scope selector & pagination
Foundation for extending the Bearer API beyond create-only:
- api_keys gains a scope column (read | read_write); baseline schema updated
  and the column applied to the live DB. Existing keys default to
  read_write so the hwmon create key keeps working.
- ApiKeyModel: createKey() takes a validated scope; validateKey() always
  surfaces scope (defaults read_write); getAllKeys() is paginated
  ({keys,total,page,perPage}, key_hash stripped).
- ApiKeyAuth: expose getKeyContext() (scope/key_name/created_by/api_key_id)
  and requireScope() (403 on insufficient scope); existing return values
  unchanged.
- create_ticket_api.php: require read_write scope (a read key can't create).
- Admin /admin/api-keys: scope selector on the create form, a scope column,
  and pagination (revoked keys were stacking up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:24:50 -04:00
jared 20e4352f24 Merge pull request 'Ship CSRF-drift + markdown fixes to production' (#25) from development into main
Lint / PHP (phpcs PSR-12) (push) Successful in 32s
Lint / JS (eslint) (push) Successful in 13s
Lint / PHP requirements (version + extensions) (push) Successful in 59s
Security / PHP Security (semgrep) (push) Successful in 1m12s
Lint / Deploy (push) Successful in 5s
Lint / Notify on failure (push) Has been skipped
2026-07-15 16:54:01 -04:00
jaredandClaude Opus 4.8 d535557e5a Strip trailing whitespace failing phpcs (unblocks CI/deploy)
Lint / PHP (phpcs PSR-12) (push) Successful in 20s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 39s
Security / PHP Security (semgrep) (push) Successful in 1m8s
Lint / Deploy (push) Successful in 2s
Lint / Notify on failure (push) Has been skipped
Lint / PHP (phpcs PSR-12) (pull_request) Successful in 36s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / PHP requirements (version + extensions) (pull_request) Successful in 20s
Security / PHP Security (semgrep) (pull_request) Successful in 1m10s
Lint / Deploy (pull_request) Has been skipped
Lint / Notify on failure (pull_request) Has been skipped
CI has been red since the CSRF-drift changes landed a trailing space on the
'success' => false line in these two endpoints, which blocks the deploy job
(and therefore beta/prod). No logic change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 14:46:04 -04:00
jaredandClaude Opus 4.8 53d3670c7f Fix markdown comments breaking on reload (template whitespace parsed as code)
Lint / PHP (phpcs PSR-12) (push) Failing after 55s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 21s
Security / PHP Security (semgrep) (push) Successful in 1m2s
Lint / Deploy (push) Has been skipped
Lint / Notify on failure (push) Successful in 2s
Stored markdown comments rendered fine in the live preview (parses the raw
textarea value) but broke after refresh: the server template emitted the
comment text on an indented line, so the on-load renderer parsed
element.textContent with ~20 spaces of leading indentation. Markdown treats
4+ leading spaces as a code block, so the first line (e.g. a heading or
table row) was mis-parsed and blocks got wrapped in <p>, producing invalid
HTML that broke the page layout.

- markdown.js: trim the text before parseMarkdown in both on-load renderers
  so template indentation can't be parsed as a leading code block.
- TicketView.php: emit the comment text inline (no surrounding whitespace)
  so the element's textContent is exactly the stored markdown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 14:42:10 -04:00
jared 8f7c669b8f Fix markdown code block parser to support language tags and UI classes
Lint / PHP (phpcs PSR-12) (push) Failing after 18s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 19s
Security / PHP Security (semgrep) (push) Successful in 56s
Lint / Deploy (push) Has been skipped
Lint / Notify on failure (push) Successful in 2s
2026-07-14 23:46:28 -04:00
jared 5dea47cd01 Fix double-parsing of markdown comments on page load
Lint / PHP (phpcs PSR-12) (push) Failing after 16s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 19s
Security / PHP Security (semgrep) (push) Successful in 1m1s
Lint / Deploy (push) Has been skipped
Lint / Notify on failure (push) Successful in 2s
2026-07-14 23:31:48 -04:00
jared 7a537f46bc Fix CSRF token drift in add_comment and update_ticket endpoints
Lint / PHP (phpcs PSR-12) (push) Failing after 50s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Security / PHP Security (semgrep) (push) Successful in 1m0s
Lint / Deploy (push) Has been skipped
Lint / Notify on failure (push) Successful in 2s
2026-07-14 23:19:26 -04:00
jared 55087bf2cb Merge pull request 'Fix bugs across data layer, API, frontend, ops (multi-agent review)' (#24) from development into main
Lint / PHP (phpcs PSR-12) (push) Successful in 33s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 25s
Security / PHP Security (semgrep) (push) Successful in 1m0s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
2026-07-10 20:26:14 -04:00
jaredandClaude Opus 4.8 622cae8bbd Fix PHP 8.4 breakage: drop deprecated mysqli::ping(), harden dep handler
Lint / PHP (phpcs PSR-12) (push) Successful in 20s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 19s
Lint / PHP (phpcs PSR-12) (pull_request) Successful in 29s
Lint / JS (eslint) (pull_request) Successful in 14s
Lint / PHP requirements (version + extensions) (pull_request) Successful in 39s
Security / PHP Security (semgrep) (push) Successful in 1m15s
Security / PHP Security (semgrep) (pull_request) Successful in 1m23s
Lint / Deploy (push) Successful in 2s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (pull_request) Has been skipped
Lint / Notify on failure (pull_request) Has been skipped
The hosts were upgraded to PHP 8.4, where mysqli::ping() is deprecated
(auto-reconnect was removed in 8.2). Database::getConnection() called it on
every reused connection, and api/ticket_dependencies.php's custom error
handler treated the deprecation as a fatal 500 ('A server error occurred'),
breaking the ticket Dependencies tab.

- Database.php: remove the redundant ping()/reconnect check (connection is
  request-scoped; no liveness check needed on PHP 8.2+).
- ticket_dependencies.php: only abort on genuine errors; log notices/
  warnings/deprecations and continue, so a future deprecation can't 500 it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 19:12:10 -04:00
jaredandClaude Opus 4.8 d6214a0339 Add schema baseline, fix cron/retention, restore cleanup, correct docs
Lint / PHP (phpcs PSR-12) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP requirements (version + extensions) (push) Successful in 21s
Security / PHP Security (semgrep) (push) Successful in 1m11s
Lint / Deploy (push) Successful in 2s
Lint / Notify on failure (push) Has been skipped
Lint / PHP (phpcs PSR-12) (pull_request) Successful in 47s
Lint / JS (eslint) (pull_request) Successful in 12s
Lint / PHP requirements (version + extensions) (pull_request) Successful in 59s
Security / PHP Security (semgrep) (pull_request) Successful in 1m6s
Lint / Deploy (pull_request) Has been skipped
Lint / Notify on failure (pull_request) Has been skipped
- migrations/000_baseline.sql: full schema baseline captured from prod
  (validated on a throwaway DB: 17 tables/17 FKs), so the schema is
  reproducible for fresh installs / disaster recovery
- create_recurring_tickets cron: send the Matrix ticket-created
  notification and invalidate the stats cache like the other create paths
- create_ticket_api.php + TicketController::create: invalidate the stats
  cache on create/escalate/reopen so dashboard counts aren't stale
- scripts/cleanup_orphan_uploads.php: restored, made safe (24h mtime
  grace, 9-digit-dir only, skips avatars/symlinks, matches the unique
  filename column, --dry-run)
- cron/cleanup_audit_log.php: enforce the configured audit-log retention
  (deleteOldLogs was implemented but never called)
- README: correct CSRF-rotation, hwmon dedup (no 24h window), SLA (no P3),
  stats-cache callers, and the project structure/endpoint listing
- .env.example: document TRUSTED_PROXIES fail-open risk and .env quoting

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:11:26 -04:00
jaredandClaude Opus 4.8 27a5db8c85 Fix views/controllers/router: command palette, create form, admin views
- Consolidate the duplicated command palette to a single overlay + init in
  the footer; fix New Ticket to route to /ticket/create (was a 404 /create);
  keep the CSP nonce and all commands
- TicketController create(): trim title, require a non-empty description,
  and honor the posted status (validated against the canonical list) instead
  of silently discarding it
- UserActivityView: 'Active Users' counts only users active in the selected
  range, not every registered user
- layout_footer/DashboardView: local esc() now escapes quotes so values used
  in HTML attributes can't break out
- TicketView: comments tab badge shows the true total, not just page one
- layout_header: gate the 'View activity log' link behind the admin flag
- index.php: validate /admin/user-activity date params; anchor the legacy
  /ticket.php route; align the audit action-type whitelist with the dropdown
- ApiKeysView: correct the external API sample to /create_ticket_api.php

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:15:40 -04:00
jaredandClaude Opus 4.8 113b7f9d3f Fix frontend JS: CSRF resync, status-comment flow, markdown/XSS, kanban
- base.js lt.api: resync window.CSRF_TOKEN from response bodies before
  throwing on errors and attach err.data/err.status, so a desynced client
  auto-recovers without a reload
- add lt.ticketStatus.submit: status changes that require a comment now
  prompt, post the comment, and retry update_ticket with it; wired into
  the ticket dropdown, dashboard quick-status, kanban drag-drop and the
  1-4 keyboard shortcuts (bulk ops unchanged) — matches the new server
  requires_comment enforcement
- base.js markdown.render: drop the unsafe marked/markdownit delegation;
  always use the built-in XSS-safe renderer
- ticket.js: XHR upload sends the X-CSRF-Token header and resyncs the
  token; use lt.escHtml instead of a re-inlined escape chain; @-mention
  trigger requires a word boundary (no firing inside emails); idempotent,
  anchor-safe highlightMentions
- base.js typeahead: discard out-of-order async results
- markdown.js: balanced table tbody/thead; ticket-ref linkification runs
  after code extraction so #ids inside code aren't linked
- dashboard.js kanban: don't swallow the click after a drag
- keyboard-shortcuts.js: J/K skip hidden/skeleton rows; drop duplicate ?

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:50:27 -04:00
jaredandClaude Opus 4.8 d11cb989bf Fix API correctness: external API stub/collision, recurring dates, CSV, audit
- create_ticket_api.php: remove the wrong CREATE TABLE stub that broke a
  fresh DB; generate collision-safe ticket_ids so a genuine id collision
  isn't misreported as a duplicate and a hw alert dropped; stop leaking
  raw DB errors; correct a reopen comment that falsely claimed refreshed
  sensor data
- manage_recurring.php: fix next-run so create/edit no longer skips the
  current period (monthly day-of-month this month, daily today if time
  not passed, correct ISO weekday, month-length clamp); only recompute
  on schedule changes to avoid double-fire
- export_tickets.php, audit_log.php: neutralize CSV formula injection
- revoke_api_key.php, generate_api_key.php: correct HTTP status codes and
  stop the catch clobbering specific 4xx codes
- health.php: stop leaking PHP version / extension names / paths to
  unauthenticated callers
- watch_ticket.php: define $data before use
- manage_templates/recurring/custom_fields: add audit logging for CRUD;
  add recurring_ticket + custom_field to the audit entity whitelist

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:26:39 -04:00
jaredandClaude Opus 4.8 327c225ded Fix API security: dependency/visibility leaks, authz, CSRF, comment spoofing
- ticket_dependencies.php: pass current user id/groups/is_admin into the
  visibility-filtered DependencyModel methods; drop (int) casts that
  stripped leading zeros from varchar ticket_ids
- update_ticket.php: authorize visibility changes (admin or creator only);
  enforce requires_comment transitions server-side (400 + requires_comment
  flag so the client can prompt-and-retry); return proper 401/400/403
- add_comment.php: take commenter name from the session not the client
  (anti-spoofing); validate parent_comment_id belongs to the ticket;
  reject empty comments; pass ticket visibility to notifications so
  non-public comment bodies aren't leaked
- add_comment/update_comment/bulk_operation: validate CSRF for all
  state-changing methods, not just POST
- bootstrap.php: return the current CSRF token on rejection and never
  rotate it on a rejected request, so a desynced client can auto-recover
- correct auth->401 and validation->400 status codes across these endpoints

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 11:48:34 -04:00
jaredandClaude Opus 4.8 c5f7a01e1d Fix helpers/config: timezone, comment leak, silent misconfig, cache perms
- Database.php: pin MySQL session time_zone to the configured named zone
  (mysql.time_zone tables now loaded on the DB) with a fixed-offset
  fallback, so NOW()/TIMESTAMP and PHP agree regardless of the DB server's
  SYSTEM tz. Best-effort, never fatals the connection.
- NotificationHelper: redact comment-body previews for internal/
  confidential tickets in sendCommentNotification and notifyWatchers so
  they are not leaked to the shared Matrix notify list (new $visibility
  param; callers wired in the API batch).
- config.php: die with a clear error if parse_ini_file fails instead of
  silently falling back to insecure defaults (empty DB pass / proxies).
- CacheHelper: create cache dir 0700 and cache files 0600 so other local
  users cannot read or poison security-relevant cached data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 11:17:07 -04:00
jaredandClaude Opus 4.8 882ab2662c Fix data-layer bugs: bind_param fatals, ticket_id bindings, cache poisoning
- CustomFieldModel: assign ?? fallbacks to variables before bind_param
  (by-reference args cannot be ?? expressions; fatal on PHP 8.2, custom
  fields were uncreatable/uneditable)
- RecurringTicketModel::create: fix swapped bind type for schedule_type
  (enum bound as int coerced 'daily' to 0, breaking the cron)
- TicketModel/CommentModel: bind varchar ticket_id as string not int so
  the unique index is usable and leading-zero IDs match; ticket_watchers
  (int column) left as integer
- TicketModel::deleteTicket: delete from custom_field_values (real table)
  not the nonexistent ticket_custom_fields
- TicketModel search: honor literal '0'; never emit AGAINST('*') on
  all-special-char input (fall back to LIKE)
- TicketModel::updateTicket: disambiguate not-found vs no-op vs genuine
  optimistic-lock conflict on zero affected rows
- WorkflowModel: do not cache transitions/statuses on DB failure (a
  transient error no longer blocks all status changes for the TTL)
- DependencyModel: filter linked tickets by visibility (new optional user
  context params) to stop confidential metadata leaking via dependencies
- BulkOperationsModel: validate status/priority/assignee before mutating
- AuditLogModel: gate getClientIP forwarded headers on trusted proxies;
  add missing action/entity types so audit-log filters work
- WorkflowModel: add transitionRequiresComment() accessor for enforcement
- CommentModel: stop leaking raw DB errors to clients (log instead)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 10:56:52 -04:00
jaredandClaude Opus 4.8 f1e172caec Shorten hwmonDaemon auto-comments (drop embedded ASCII description)
Security / PHP Security (semgrep) (push) Successful in 2m12s
Lint / Deploy (push) Successful in 4s
Lint / Notify on failure (push) Has been skipped
Lint / PHP (phpcs PSR-12) (push) Successful in 1m4s
Lint / JS (eslint) (push) Successful in 14s
Lint / PHP requirements (version + extensions) (push) Successful in 37s
The priority-escalation and recurrence comments embedded the full ASCII
alert description in a code block, producing a wall-of-text comment every
time. Since the ticket DESCRIPTION is already refreshed with the current
sensor data on each update, the comment only needs to record the event:

- Escalation: short note with from/to priority labels + a brief reason
  ("more severe condition reported, needs faster attention; see description").
- Recurrence: short reopened note pointing at the refreshed description.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:33:59 -04:00
jaredandClaude Opus 4.8 94ad84dae9 CI: pin actions/checkout to a commit SHA
Lint / Deploy (push) Successful in 4s
Lint / Notify on failure (push) Has been skipped
Lint / PHP (phpcs PSR-12) (push) Successful in 34s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 44s
Security / PHP Security (semgrep) (push) Successful in 2m48s
semgrep's github-actions-mutable-action-tag rule (now running, after the
pip install was fixed) flags actions/checkout@v3 as a mutable tag that
could be repointed upstream (supply-chain risk). Pin all four uses to the
SHA the v3 tag currently resolves to (v3.6.0), preserving behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:53:38 -04:00
jaredandClaude Opus 4.8 99c840fce0 Fix logic bugs found in third multi-agent review
Security / PHP Security (semgrep) (push) Failing after 2m44s
Lint / Deploy (push) Successful in 8s
Lint / Notify on failure (push) Has been skipped
Lint / PHP (phpcs PSR-12) (push) Successful in 18s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 21s
Medium:
- create_ticket_api.php: environment tags were parsed with explode('][') which
  left brackets on the first/last tag so the whitelist never matched, dropping
  the env tag from the dedup hash — a [production] and [staging] issue with
  otherwise-identical components could collide onto one ticket. Use a
  bracket-aware regex.
- CommentModel::getThreadedCommentsPaged only fetched DIRECT children of root
  comments, so when pagination is active, nested replies at depth 2-3 vanished
  from the thread. Expand replies level-by-level (bounded to depth 3).
- StatsModel::getTicketsByAssignee ignored the visibility filter the rest of the
  stats apply, so a non-admin's "by assignee" widget counted (leaked) confidential
  tickets. Thread the same filter through.
- watch_ticket.php GET path returned watch state / watcher names / count for any
  ticket with no access check (the POST path checks it) — added canUserAccessTicket.
- dashboard.js kanban: every card rendered as P4 because the [class*="lt-p"]
  selector never matched the lt-badge-p1 class and the fallback didn't strip "P".
  Extract the digit directly.

Low:
- audit_log.php CSV: "Log ID" column was always blank ($log['log_id'] vs the real
  audit_id column). Use audit_id.
- check_duplicates.php: the graceful-degradation try/catch only covered the throw
  path; guard the false-return (non-exception mysqli) path too.
- notifications.php: owner-who-is-also-@mentioned got two notifications for one
  comment; drop the duplicate comment row when a mention covers the same comment.
- dashboard.js hover preview rendered "PP1" (doubled prefix); strip the leading P.
- markdown.js: code/inline-code restore used string replace, so $&, $$, $`, $' in
  user code were treated as replacement patterns; use a function replacer. Also
  removed an unused loop var.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:21:35 -04:00
jaredandClaude Opus 4.8 9941fd2dfa Address remaining review items: Synapse caching, cycle detection, cache/ratelimit/kanban
Security / PHP Security (semgrep) (push) Successful in 1m45s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
Lint / PHP (phpcs PSR-12) (push) Successful in 21s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 26s
- SynapseHelper: memoize username->Matrix-ID lookups per-request (incl. negative
  results) and add an overall time budget to resolveUsernames() plus a 2s connect
  timeout, so notifying N watchers with a slow/unreachable Synapse can't stall the
  request for N x 5s. (Chosen over async/queue per maintainer.)
- DependencyModel: fix cycle detection treating 'blocks' and 'blocked_by' as the
  same edge direction. They are inverse relationships (single row each, no mirror
  row), so the traversal now walks a unified precedence graph (blocks: ticket->
  depends_on; blocked_by: depends_on->ticket) and wouldCreateCycle normalizes the
  new edge's direction. Prevents both false-positive and missed cycles.
- CacheHelper: anchor prefix-delete to exact key boundaries (bare prefix or
  prefix + '_' + md5) so delete('workflow') can't wipe a 'workflow_rules' cache.
- RateLimitMiddleware: hold an exclusive flock across the per-IP counter's
  read-modify-write so concurrent requests can't both read N and write N+1
  (undercounting past the limit). Fails open if the file can't be locked.
- dashboard.js: kanban status update now uses lt.api.post (per no-raw-fetch
  convention) and reverts the card AND the optimistic column counts on failure
  (the old raw-fetch catch left the card moved without reverting).
- BulkOperationsModel: document that bulk_status/bulk_close intentionally bypass
  workflow transition validation (admin override, by design).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:55:50 -04:00
76 changed files with 3859 additions and 1064 deletions
+56 -24
View File
@@ -1,47 +1,79 @@
# Tinker Tickets Environment Configuration
# Copy this file to .env and fill in your values
; Tinker Tickets Environment Configuration
; Copy this file to .env and fill in your values
;
; NOTE: This file is parsed with PHP's parse_ini_file. Any value containing
; special characters -- #, ;, =, quotes, spaces, etc. -- MUST be wrapped in
; double quotes, e.g. DB_PASS="p@ss;word#1". The application now fails loudly
; -- dies with a clear error -- if the .env file cannot be parsed, so an
; unquoted special character will take the whole app down rather than
; silently using a wrong value.
;
; Comments in this file use ";" rather than "#": PHP's ini parser treats "#"
; comments as fragile -- punctuation like parentheses or quotes inside a "#"
; comment can produce a syntax error even though the line is meant to be
; inert, silently breaking every value below it. ";" comments don't have this
; problem, so keep using ";" for any comment added to this file.
# Database Configuration
; Database Configuration
DB_HOST=10.10.10.50
DB_USER=tinkertickets
DB_PASS=your_password_here
DB_NAME=ticketing_system
# Matrix Webhook (optional - for notifications via matrix-hookshot)
# Set to your hookshot generic webhook URL, e.g.:
# https://matrix.lotusguild.org/webhook/<uuid>
; Matrix Webhook (optional - for notifications via matrix-hookshot)
; Set to your hookshot generic webhook URL, e.g.:
; https://matrix.lotusguild.org/webhook/uuid-goes-here
MATRIX_WEBHOOK_URL=
# Matrix users to @mention on every new ticket (comma-separated Matrix user IDs)
# e.g. @jared:matrix.lotusguild.org,@alice:matrix.lotusguild.org
; Matrix users to @mention on every new ticket (comma-separated Matrix user IDs)
; e.g. @jared:matrix.lotusguild.org,@alice:matrix.lotusguild.org
MATRIX_NOTIFY_USERS=
# Application Domain (required for Matrix webhook ticket links)
# Set this to your public domain (e.g., t.lotusguild.org)
; Matrix homeserver domain (used to build Matrix user IDs from LLDAP usernames)
MATRIX_DOMAIN=
; Synapse internal URL and admin token (used to resolve usernames -> Matrix IDs
; for watcher DMs)
SYNAPSE_ADMIN_URL=
SYNAPSE_ADMIN_TOKEN=
; Optional: send a Matrix notification on comments and/or assignments (0/1)
MATRIX_NOTIFY_COMMENTS=0
MATRIX_NOTIFY_ASSIGNMENTS=0
; Application Domain (required for Matrix webhook ticket links)
; Set this to your public domain, e.g. t.lotusguild.org
APP_DOMAIN=
# Allowed Hosts for HTTP_HOST validation (comma-separated)
# Include all domains that can access this application
; Allowed Hosts for HTTP_HOST validation (comma-separated)
; Include all domains that can access this application
ALLOWED_HOSTS=localhost,127.0.0.1
# Trusted reverse proxy IP(s), comma-separated (e.g. the Authelia/nginx proxy).
# STRONGLY RECOMMENDED in production: Authelia forward-auth (Remote-User /
# Remote-Groups) and forwarded client IPs are only trusted when REMOTE_ADDR is
# in this list. Leaving it empty disables that protection (relies solely on
# network topology) and lets anything reaching PHP directly spoof admin login.
# Exact IP match only (no CIDR). Example: TRUSTED_PROXIES=10.10.10.27
; Trusted reverse proxy IPs, comma-separated -- e.g. the Authelia/nginx proxy.
; Set this to the IP address(es) of your reverse proxy. Authelia forward-auth
; headers (Remote-User / Remote-Groups) and forwarded client IPs are only
; trusted when REMOTE_ADDR is in this list.
;
; Leaving this EMPTY disables reverse-proxy verification entirely: the app then
; trusts Remote-User / Remote-Groups headers from ANY source. That is unsafe if
; the PHP backend is reachable directly (bypassing the proxy), because a client
; can then spoof those headers and log in as an admin. Only leave it empty when
; network topology guarantees PHP is reachable solely via the trusted proxy.
;
; Exact IP match only (no CIDR). Example (single proxy): TRUSTED_PROXIES=10.10.10.27
; Example (multiple): TRUSTED_PROXIES=10.10.10.27,10.10.10.28
TRUSTED_PROXIES=
# Timezone (default: America/New_York)
; Timezone (default: America/New_York)
TIMEZONE=America/New_York
# LDAP / lldap (for user avatar lookups)
; LDAP / lldap (for user avatar lookups)
LDAP_ENABLED=true
LDAP_HOST=10.10.10.39
LDAP_PORT=3890
LDAP_BIND_DN=uid=tinker-tickets,ou=people,dc=example,dc=com
LDAP_BIND_DN="uid=tinker-tickets,ou=people,dc=example,dc=com"
LDAP_BIND_PW=
LDAP_BASE_DN=dc=example,dc=com
LDAP_USER_BASE=ou=people,dc=example,dc=com
# How long to cache avatar images locally (seconds, default 3600)
LDAP_BASE_DN="dc=example,dc=com"
LDAP_USER_BASE="ou=people,dc=example,dc=com"
; How long to cache avatar images locally (seconds, default 3600)
AVATAR_CACHE_TTL=3600
+3 -3
View File
@@ -11,7 +11,7 @@ jobs:
name: PHP (phpcs PSR-12)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- name: Install PHP and phpcs
run: |
@@ -27,7 +27,7 @@ jobs:
name: JS (eslint)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- name: Install ESLint
run: npm install --save-dev eslint@8
@@ -39,7 +39,7 @@ jobs:
name: PHP requirements (version + extensions)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- name: Install PHP with required extensions
run: |
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
name: PHP Security (semgrep)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- name: Install semgrep
run: |
+52 -17
View File
@@ -73,7 +73,7 @@ The following features are intentionally **not planned** for this system:
- **Duplicate Detection**: Similarity check on ticket title surfaces potential duplicates with one-click linking
- **Activity Timeline**: Full `lt-timeline` audit trail — color-coded by event type (status, comment, assign, attach)
- **Watcher Avatars**: Avatar group shows who is watching a ticket; tooltip lists all names
- **SLA Timer**: P1/P2 tickets display a live elapsed-time banner with progress bar (P1 = 8 h, P2 = 24 h, P3 = 72 h)
- **SLA Timer**: P1/P2 tickets display a live elapsed-time banner with progress bar (P1 = 8 h, P2 = 24 h). Lower priorities (P3P5) have no SLA banner.
- **Priority Alert Banner**: P1 shows a sticky error banner; P2 shows a warning banner — dismissible per session
### Ticket Templates
@@ -94,11 +94,22 @@ The following features are intentionally **not planned** for this system:
- **Required Fields**: Mark fields as required for validation
### API Key Management
- **Admin UI**: Generate and manage API keys at `/admin/api-keys`
- **Admin UI**: Generate and manage API keys at `/admin/api-keys` (paginated)
- **Bearer Token Auth**: Use API keys with `Authorization: Bearer YOUR_KEY` header
- **Key Scopes**: `read` (GET only) or `read_write` (create/comment/close). A `read` key cannot mutate anything, including creating tickets. Existing keys default to `read_write`.
- **Expiration**: Optional expiration dates for keys
- **Revocation**: Revoke compromised keys instantly
### Bearer API (automation / triage)
All Bearer-authenticated, rate-limited, and (like `create_ticket_api.php`) exempt from Authelia at the reverse proxy — the API key is the only credential. Comments/closes made via the API are attributed to the **key's name** (linked to the key's owner).
| Endpoint | Method | Scope | Purpose |
|----------|--------|-------|---------|
| `/create_ticket_api.php` | POST | read_write | Create a ticket (hwmonDaemon, external tools) |
| `/api/tickets_api.php` | GET | read | List/triage the queue (`?status=`, `?priority=`, `?host=` [title match], `?page=`, `?limit=`) **or** read one (`?ticket_id=NNN`) with its comments |
| `/api/ticket_comment_api.php` | POST | read_write | Add a comment: `{ticket_id, comment_text, markdown_enabled?}` |
| `/api/ticket_status_api.php` | POST | read_write | Change/close status (workflow-validated): `{ticket_id, status, comment?}``comment` is required for transitions that require one (e.g. → Closed); it is posted as the close reason in the same call |
### User Management & Authentication
- **SSO Integration**: Authelia authentication with LLDAP backend
- **Role-Based Access**: Admin and standard user roles
@@ -121,7 +132,7 @@ The following features are intentionally **not planned** for this system:
- **Powered by audit_log**: No extra table — notifications are derived from existing audit trail
### Matrix Notifications (hookshot)
- **Ticket Created**: Fires when any ticket is created (manual or via API)
- **Ticket Created**: Fires when a ticket is created via the manual form, the external API (hwmonDaemon), or the recurring-ticket cron. (Cloned tickets do not fire this event.)
- **Status Changed**: Fires on every status transition
- **@Mentions**: Mentioned users receive a direct Matrix notification
- **Assignment**: Optional — set `MATRIX_NOTIFY_ASSIGNMENTS=1` to enable
@@ -150,7 +161,7 @@ The following features are intentionally **not planned** for this system:
| `?` | Show keyboard shortcuts help |
### Security Features
- **CSRF Protection**: Token-based protection with constant-time comparison; token rotated after each write
- **CSRF Protection**: Token-based protection with constant-time comparison. `bootstrap.php` rotates the token on a successful write and returns the current token in every response (including on rejection); the client (`lt.api`) resyncs from that value. Rejected requests do not rotate the token.
- **Rate Limiting**: Session-based AND IP-based rate limiting to prevent abuse
- **Security Headers**: CSP with nonces (no unsafe-inline), X-Frame-Options, X-Content-Type-Options
- **SQL Injection Prevention**: All queries use prepared statements with parameter binding
@@ -179,9 +190,9 @@ Content-Type: application/json
**Key behaviours:**
- Authenticated via `Authorization: Bearer` header — API key stored in `/etc/hwmonDaemon/.env`
- **Deduplication**: Generates a SHA-256 hash from the issue category, hostname, and device; rejects duplicate tickets within 24 hours
- **Deduplication**: Generates a SHA-256 hash from the issue category, hostname, and device (no time window). A repeat alert matching an existing **open** ticket updates its title/description and escalates the priority if the condition worsened; if the matching ticket was already **closed**, it is reopened instead of creating a new one
- Cluster-wide issues (Ceph health, etc.) deduplicate across all nodes (hostname excluded from hash)
- Matrix notification sent automatically after ticket creation
- Matrix notification sent automatically on ticket creation, priority escalation, and reopen
- API key must be generated at `/admin/api-keys`; the key goes in hwmonDaemon's `/etc/hwmonDaemon/.env` as `TICKET_API_KEY`
## Technical Architecture
@@ -240,14 +251,23 @@ Content-Type: application/json
- `tickets`: `ticket_id` (unique), `status`, `priority`, `created_at`, `created_by`, `assigned_to`, `visibility`
- `audit_log`: `user_id`, `action_type`, `entity_type`, `created_at`
### Database Schema / Migrations
- `migrations/000_baseline.sql` is the full schema baseline for the whole database. It is written to be safe to re-run (idempotent) and is the source of truth for a fresh install.
- `php migrations/migrate.php` applies any pending migration files in `migrations/` in order, tracking applied files in the `migrations` table. Use `--status` to list state and `--dry-run` to preview without executing.
### API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/create_ticket_api.php` | POST | Create ticket via API key (hwmonDaemon, external tools) |
| `/api/tickets_api.php` | GET | Bearer: list/triage queue or read one ticket + comments |
| `/api/ticket_comment_api.php` | POST | Bearer: add a comment (read_write scope) |
| `/api/ticket_status_api.php` | POST | Bearer: change/close status, workflow-validated (read_write scope) |
| `/api/update_ticket.php` | POST | Update ticket with workflow validation |
| `/api/assign_ticket.php` | POST | Assign ticket to user |
| `/api/add_comment.php` | POST | Add comment to ticket |
| `/api/get_comments.php` | GET | Fetch paginated comments for a ticket |
| `/api/clone_ticket.php` | POST | Clone an existing ticket |
| `/api/get_template.php` | GET | Fetch ticket template |
| `/api/get_users.php` | GET | Get user list for assignments |
@@ -292,6 +312,7 @@ tinker_tickets/
│ ├── download_attachment.php # GET: Download with visibility check
│ ├── export_tickets.php # GET: Export tickets to CSV/JSON
│ ├── generate_api_key.php # POST: Generate API key (admin)
│ ├── get_comments.php # GET: Fetch paginated ticket comments
│ ├── get_template.php # GET: Fetch ticket template
│ ├── get_users.php # GET: Get user list
│ ├── health.php # GET: Health check endpoint
@@ -329,14 +350,19 @@ tinker_tickets/
├── config/
│ └── config.php # Config + .env loading
├── controllers/
│ ├── CommentController.php # Comment create/edit/delete + notifications
│ ├── DashboardController.php # Dashboard with stats + filters
│ └── TicketController.php # Ticket CRUD + timeline + visibility
├── cron/
│ ├── cleanup_audit_log.php # Delete audit_log rows past retention (daily)
│ ├── cleanup_ratelimit.php # Purge expired rate-limit files (every few min)
│ └── create_recurring_tickets.php # Process recurring ticket schedules
├── helpers/
│ ├── CacheHelper.php # File-based cache (stats, avatars)
│ ├── Database.php # Centralized mysqli connection
│ ├── ErrorHandler.php # Global error/exception handler
│ ├── NotificationHelper.php # Matrix hookshot webhook events
│ ├── ResponseHelper.php # JSON API response helpers
│ ├── SynapseHelper.php # Resolves usernames → Matrix IDs via Synapse admin API
│ └── UrlHelper.php # Canonical ticket URLs using APP_DOMAIN
├── middleware/
@@ -347,6 +373,7 @@ tinker_tickets/
│ └── SecurityHeadersMiddleware.php # CSP headers with per-request nonce generation
├── models/
│ ├── ApiKeyModel.php # API key generation/validation
│ ├── AttachmentModel.php # Ticket file attachment metadata
│ ├── AuditLogModel.php # Audit logging + timeline
│ ├── BulkOperationsModel.php # Bulk operations tracking
│ ├── CommentModel.php # Comment data access
@@ -360,11 +387,12 @@ tinker_tickets/
│ ├── UserModel.php # User management + groups
│ ├── UserPreferencesModel.php # User preferences
│ └── WorkflowModel.php # Status transition workflows
├── migrations/
│ ├── 000_baseline.sql # Full schema baseline (safe to re-run)
│ └── migrate.php # CLI migration runner (tracks applied migrations)
├── scripts/
│ ├── add_closed_at_column.php # Migration: add closed_at column to tickets
── add_comment_updated_at.php # Migration: add updated_at column to ticket_comments
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads (run manually or via cron)
│ └── create_dependencies_table.php # Create ticket_dependencies table
│ ├── check_requirements.php # Verify PHP extensions/config prerequisites
── cleanup_orphan_uploads.php # Delete orphaned upload files past grace period (cron)
├── uploads/ # File attachment storage
│ └── avatars/ # lldap avatar disk cache
├── views/
@@ -455,13 +483,20 @@ AVATAR_CACHE_TTL=3600
### 2. Cron Jobs
Add to crontab for recurring tickets and optional cleanup:
Add to crontab for recurring tickets and maintenance cleanup:
```bash
# Run every hour to create scheduled recurring tickets
0 * * * * php /path/to/tinkertickets/cron/create_recurring_tickets.php
# Optional: clean up orphaned uploads weekly
0 3 * * 0 php /path/to/tinkertickets/scripts/cleanup_orphan_uploads.php
# Purge expired rate-limit files (every 5 minutes)
*/5 * * * * php /path/to/tinkertickets/cron/cleanup_ratelimit.php
# Delete audit_log rows older than AUDIT_LOG_RETENTION_DAYS (daily)
30 3 * * * php /path/to/tinkertickets/cron/cleanup_audit_log.php
# Delete orphaned upload files with no attachment row, past a 24h grace period (daily).
# Add --dry-run to preview without deleting.
0 4 * * * php /path/to/tinkertickets/scripts/cleanup_orphan_uploads.php
```
### 3. File Uploads
@@ -502,7 +537,7 @@ Key conventions and gotchas for working with this codebase:
3. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
4. **Config path**: `config/config.php` (not `config/db.php`)
5. **Comments table**: `ticket_comments` (not `comments`)
6. **CSRF**: Required for all POST/DELETE requests via `X-CSRF-Token` header; bootstrap.php rotates token and returns it in `csrf_token` field of all `apiRespond()` responses
6. **CSRF**: Required for all POST/DELETE requests via `X-CSRF-Token` header. `bootstrap.php` rotates the token only on a successful write and returns the current token in the `csrf_token` field of every `apiRespond()` response (including rejections), so the client can resync. A rejected request keeps the existing token.
7. **Cache busting**: `ASSET_VERSION` is auto-computed from asset file mtimes; override with `ASSET_VERSION=` in `.env`
8. **Ticket linking**: Use `#123456789` in markdown-enabled comments
9. **User groups**: Stored in `users.groups` as comma-separated values
@@ -520,8 +555,8 @@ Key conventions and gotchas for working with this codebase:
21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`.
22. **`utils.js` on all pages**: `utils.js` is loaded by all views (including admin). It provides `escapeHtml()`, `getTicketIdFromUrl()`, and `showConfirmModal()`.
23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`.
24. **Stats cache**: `StatsModel` caches stats for 60 s. Any API that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after changes (bulk_operation, assign_ticket, update_ticket, clone_ticket all do this).
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 to prevent duplicate hw-alert tickets within 24 h.
24. **Stats cache**: `StatsModel` caches stats for 60 s. Any path that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after the change. Callers: `TicketController::create` (manual create), `create_ticket_api.php` (external API create/escalate/reopen), `cron/create_recurring_tickets.php`, `bulk_operation`, `assign_ticket`, `update_ticket`, `clone_ticket`, and `ticket_status_api.php` (Bearer API status-change endpoint).
25. **External API (`create_ticket_api.php`)**: Uses `ApiKeyAuth` (Bearer token), not session auth. Served directly by the web server from the document root — not through the index.php router. Includes deduplication logic (SHA-256 hash, no time window) that updates/escalates an existing open duplicate or reopens a closed one rather than creating a new ticket.
## File Reference
@@ -557,7 +592,7 @@ Key conventions and gotchas for working with this codebase:
|---------|---------------|
| SQL Injection | All queries use prepared statements with parameter binding |
| XSS Prevention | HTML escaped in markdown parser; CSP with per-request nonces |
| CSRF Protection | Token-based with constant-time comparison (`hash_equals`); rotated on each write |
| CSRF Protection | Token-based with constant-time comparison (`hash_equals`); rotated on successful writes, current token returned in every response (including rejections) for the client to resync — rejected requests do not rotate |
| Session Security | Fixation prevention, secure cookies, session timeout |
| Rate Limiting | Session-based + IP-based (file storage) |
| File Security | Path traversal prevention, MIME type validation, uploads `.htaccess` blocks execution |
+64 -7
View File
@@ -38,19 +38,29 @@ try {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
ob_end_clean();
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// CSRF Protection
// CSRF Protection for all state-changing methods (any non-GET/HEAD request)
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode([
'success' => false,
'error' => 'Invalid CSRF token',
'csrf_token' => CsrfMiddleware::getToken()
]);
exit;
}
// Rotate token after successful validation
$newCsrfToken = CsrfMiddleware::rotateToken();
}
$currentUser = $_SESSION['user'];
@@ -63,7 +73,11 @@ try {
$data = json_decode(file_get_contents('php://input'), true);
if (!$data) {
throw new Exception("Invalid JSON data received");
http_response_code(400);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid JSON data received']);
exit;
}
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
@@ -75,6 +89,25 @@ try {
exit;
}
// Reject empty/whitespace-only comments
$commentTextRaw = isset($data['comment_text']) ? trim((string)$data['comment_text']) : '';
if ($commentTextRaw === '') {
http_response_code(400);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Comment text cannot be empty']);
exit;
}
// Persist the trimmed text (not the raw client value) — matches update_comment.php
// and keeps stored comment_text free of leading whitespace that could shift a
// markdown-enabled comment's first line out of column 0 on reload.
$data['comment_text'] = $commentTextRaw;
// Never trust a client-supplied display name — always attribute the comment to
// the authenticated session user.
$data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
// Verify user can access the ticket before allowing a comment
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
@@ -97,6 +130,18 @@ try {
$commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn);
// If replying, the parent comment must belong to this same (accessible) ticket.
if (isset($data['parent_comment_id']) && $data['parent_comment_id'] !== null && $data['parent_comment_id'] !== '') {
$parentComment = $commentModel->getCommentById((int)$data['parent_comment_id']);
if (!$parentComment || (string)$parentComment['ticket_id'] !== (string)$ticketId) {
http_response_code(400);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid parent comment']);
exit;
}
}
// Extract @mentions from comment text
$mentions = $commentModel->extractMentions($data['comment_text'] ?? '');
$mentionedUsers = [];
@@ -130,6 +175,7 @@ try {
$authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$commentText = $data['comment_text'] ?? '';
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
$ticketVisibility = $ticket['visibility'] ?? 'public';
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API
if (!empty($mentionedUsers)) {
@@ -142,7 +188,14 @@ try {
// General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
NotificationHelper::sendCommentNotification($ticketId, $ticketTitle, $commentText, $authorDisplay);
NotificationHelper::sendCommentNotification(
$ticketId,
$ticketTitle,
$commentText,
$authorDisplay,
$ticketVisibility !== 'public',
$ticketVisibility
);
}
// Notify watchers of the new comment
@@ -152,7 +205,8 @@ try {
$ticketTitle,
'comment_added',
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
(int)$userId
(int)$userId,
$ticketVisibility
);
// Add mentioned users to result for frontend
@@ -165,6 +219,9 @@ try {
if ($result['success']) {
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
$result['user_id'] = $userId;
if (isset($newCsrfToken)) {
$result['csrf_token'] = $newCsrfToken;
}
}
// Discard any unexpected output
+19 -3
View File
@@ -9,6 +9,22 @@
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
/**
* Neutralize CSV/formula injection: prefix a leading apostrophe to any cell that
* a spreadsheet (Excel/Sheets) would otherwise evaluate as a formula.
*
* @param mixed $value
* @return string
*/
function auditCsvSafeCell($value): string
{
$value = (string)$value;
if ($value !== '' && in_array($value[0], ['=', '+', '-', '@', "\t", "\r"], true)) {
return "'" . $value;
}
return $value;
}
// Check admin status - audit log viewing is admin-only
if (!$isAdmin) {
http_response_code(403);
@@ -69,8 +85,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$details = json_encode($log['details']);
}
fputcsv($output, [
$log['log_id'],
fputcsv($output, array_map('auditCsvSafeCell', [
$log['audit_id'] ?? ($log['log_id'] ?? ''),
$log['created_at'],
$log['display_name'] ?? $log['username'] ?? 'N/A',
$log['action_type'],
@@ -78,7 +94,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$log['entity_id'] ?? 'N/A',
$log['ip_address'] ?? 'N/A',
$details
]);
]));
}
fclose($output);
+8 -1
View File
@@ -34,9 +34,16 @@ if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'DELETE'])) {
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
// Do NOT rotate on a rejected request. Return the current valid token so a
// client whose token drifted out of sync can recover on its next request
// (the response body is same-origin only, so this can't aid a CSRF attacker).
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode([
'success' => false,
'error' => 'Invalid CSRF token',
'csrf_token' => CsrfMiddleware::getToken()
]);
exit;
}
// Rotate token after successful validation; endpoints include it in their JSON response
+13 -4
View File
@@ -19,9 +19,9 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
exit;
}
// CSRF Protection
// CSRF Protection for all state-changing methods (any non-GET/HEAD request)
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
@@ -47,6 +47,7 @@ $parameters = $data['parameters'] ?? null;
// Validate input
$validOperationTypes = ['bulk_close', 'bulk_assign', 'bulk_priority', 'bulk_status', 'bulk_delete'];
if (!$operationType || !in_array($operationType, $validOperationTypes, true) || empty($ticketIds)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Operation type and ticket IDs required']);
exit;
}
@@ -57,6 +58,7 @@ $ticketIds = array_values(array_filter(array_map(function ($id) {
return (ctype_digit($s) && (int)$s > 0) ? $s : null;
}, $ticketIds)));
if (empty($ticketIds)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'No valid ticket IDs provided']);
exit;
}
@@ -105,10 +107,17 @@ $result = $bulkOpsModel->processBulkOperation($operationId);
if (isset($result['error'])) {
$conn->close();
echo json_encode([
$response = [
'success' => false,
'error' => $result['error']
]);
];
// Let the client know it should collect a comment and retry, rather than
// showing the failure as a dead end.
if (!empty($result['requires_comment'])) {
$response['requires_comment'] = true;
http_response_code(400);
}
echo json_encode($response);
} else {
// Invalidate stats cache so dashboard tiles reflect changes immediately
require_once dirname(__DIR__) . '/models/StatsModel.php';
+5
View File
@@ -64,6 +64,11 @@ try {
}
$stmt->execute();
$result = $stmt->get_result();
if ($result === false) {
// Non-exception mysqli mode: execute/get_result return false instead of
// throwing. Treat as a query failure so we don't fatal on $result below.
throw new RuntimeException('query failed: ' . $conn->error);
}
} catch (Throwable $e) {
error_log('check_duplicates: ' . $e->getMessage());
ResponseHelper::success(['duplicates' => []]);
+25
View File
@@ -15,6 +15,7 @@ try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication
if (session_status() === PHP_SESSION_NONE) {
@@ -50,6 +51,8 @@ try {
header('Content-Type: application/json');
$model = new CustomFieldModel($conn);
$auditLog = new AuditLogModel($conn);
$currentUserId = $_SESSION['user']['user_id'];
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
$category = isset($_GET['category']) ? $_GET['category'] : null;
@@ -75,6 +78,13 @@ try {
exit;
}
$result = $model->createDefinition($data);
if (!empty($result['success'])) {
$auditLog->log($currentUserId, 'create', 'custom_field', (string)($result['field_id'] ?? ''), [
'field_name' => $data['field_name'] ?? null,
'field_label' => $data['field_label'] ?? null,
'field_type' => $data['field_type'] ?? null
]);
}
echo json_encode($result);
break;
@@ -92,6 +102,14 @@ try {
exit;
}
$result = $model->updateDefinition($id, $data);
if (!empty($result['success'])) {
$auditLog->log($currentUserId, 'update', 'custom_field', (string)$id, [
'entity' => 'custom_field',
'field_name' => $data['field_name'] ?? null,
'field_label' => $data['field_label'] ?? null,
'field_type' => $data['field_type'] ?? null
]);
}
echo json_encode($result);
break;
@@ -102,7 +120,14 @@ try {
exit;
}
$toDelete = $model->getDefinition($id);
$result = $model->deleteDefinition($id);
if (!empty($result['success'])) {
$auditLog->log($currentUserId, 'delete', 'custom_field', (string)$id, [
'entity' => 'custom_field',
'field_name' => $toDelete['field_name'] ?? 'unknown'
]);
}
echo json_encode($result);
break;
+10 -2
View File
@@ -36,7 +36,11 @@ try {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
ob_end_clean();
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// CSRF Protection
@@ -64,7 +68,11 @@ try {
if (isset($_POST['comment_id'])) {
$data = ['comment_id' => $_POST['comment_id']];
} else {
throw new Exception("Missing required field: comment_id");
ob_end_clean();
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Missing required field: comment_id']);
exit;
}
}
+17 -1
View File
@@ -15,6 +15,22 @@ error_reporting(E_ALL);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
/**
* Neutralize CSV/formula injection: prefix a leading apostrophe to any cell that
* a spreadsheet (Excel/Sheets) would otherwise evaluate as a formula.
*
* @param mixed $value
* @return string
*/
function exportCsvSafeCell($value): string
{
$value = (string)$value;
if ($value !== '' && in_array($value[0], ['=', '+', '-', '@', "\t", "\r"], true)) {
return "'" . $value;
}
return $value;
}
try {
// Include required files
require_once dirname(__DIR__) . '/config/config.php';
@@ -124,7 +140,7 @@ try {
$ticket['updated_at'],
$ticket['description']
];
fputcsv($output, $row);
fputcsv($output, array_map('exportCsvSafeCell', $row));
}
fclose($output);
+37 -8
View File
@@ -24,11 +24,13 @@ try {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
throw new Exception("Authentication required");
}
// Check admin privileges
if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) {
http_response_code(403);
throw new Exception("Admin privileges required");
}
@@ -51,17 +53,27 @@ try {
// Get request data
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
http_response_code(400);
throw new Exception("Invalid request data");
}
$keyName = trim($input['key_name'] ?? '');
$expiresInDays = $input['expires_in_days'] ?? null;
$scope = $input['scope'] ?? 'read_write';
if (empty($keyName)) {
http_response_code(400);
throw new Exception("Key name is required");
}
// Validate scope — only the two known values are allowed
if (!in_array($scope, ['read', 'read_write'], true)) {
http_response_code(400);
throw new Exception("Invalid scope: must be 'read' or 'read_write'");
}
if (strlen($keyName) > 100) {
http_response_code(400);
throw new Exception("Key name must be 100 characters or less");
}
@@ -69,6 +81,7 @@ try {
if ($expiresInDays !== null && $expiresInDays !== '') {
$expiresInDays = (int)$expiresInDays;
if ($expiresInDays < 1 || $expiresInDays > 3650) {
http_response_code(400);
throw new Exception("Expiration must be between 1 and 3650 days");
}
} else {
@@ -80,7 +93,7 @@ try {
// Generate API key
$apiKeyModel = new ApiKeyModel($conn);
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays);
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays, $scope);
if (!$result['success']) {
throw new Exception($result['error'] ?? "Failed to generate API key");
@@ -93,7 +106,7 @@ try {
'create',
'api_key',
$result['key_id'],
['key_name' => $keyName, 'expires_in_days' => $expiresInDays]
['key_name' => $keyName, 'expires_in_days' => $expiresInDays, 'scope' => $scope]
);
// Clear output buffer
@@ -106,15 +119,31 @@ try {
'api_key' => $result['api_key'],
'key_prefix' => $result['key_prefix'],
'key_id' => $result['key_id'],
'scope' => $result['scope'],
'expires_at' => $result['expires_at']
]);
} catch (Exception $e) {
ob_end_clean();
error_log("Generate API key error: " . $e->getMessage());
header('Content-Type: application/json');
http_response_code(isset($conn) ? 400 : 500);
echo json_encode([
'success' => false,
'error' => 'An internal error occurred'
]);
// Preserve any specific status set before the throw (401/403/400/...);
// only fall back to 500 when nothing more specific was set.
$code = http_response_code();
if (!is_int($code) || $code < 400) {
$code = 500;
}
http_response_code($code);
if ($code >= 500) {
error_log("Generate API key error: " . $e->getMessage());
echo json_encode([
'success' => false,
'error' => 'An internal error occurred'
]);
} else {
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
+4 -2
View File
@@ -8,8 +8,10 @@
require_once __DIR__ . '/bootstrap.php';
try {
// Get all users for mentions/assignment
$result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username");
// Get all users for mentions/assignment. Capped as defense-in-depth against
// a single call scraping an unbounded user list — every caller only needs
// this for typeahead/dropdown filtering, never a literal full roster.
$result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username LIMIT 500");
if (!$result) {
throw new Exception("Failed to query users");
+42 -1
View File
@@ -129,17 +129,58 @@ if (version_compare(PHP_VERSION, $requirements['min_php_version'], '>=')) {
$healthy = false;
}
// Check 7: memory_limit / max_execution_time sanity (warnings, not fatal — a
// low default doesn't fail requests until something large actually runs, so
// surface it here rather than waiting for a mysterious failure under load).
$memLimitIni = ini_get('memory_limit');
$memLimitUnit = strtolower(substr(trim($memLimitIni), -1));
$memLimitBytes = $memLimitIni === '-1'
? -1
: (int)$memLimitIni * match ($memLimitUnit) {
'g' => 1024 * 1024 * 1024,
'm' => 1024 * 1024,
'k' => 1024,
default => 1,
};
$minMemBytes = $requirements['min_memory_limit_mb'] * 1024 * 1024;
if ($memLimitBytes === -1 || $memLimitBytes >= $minMemBytes) {
$checks['memory_limit'] = ['status' => 'ok', 'message' => $memLimitIni];
} else {
$checks['memory_limit'] = [
'status' => 'warning',
'message' => sprintf('%s is below the recommended minimum %dM', $memLimitIni, $requirements['min_memory_limit_mb'])
];
}
$maxExecTime = (int)ini_get('max_execution_time');
if ($maxExecTime === 0 || $maxExecTime >= $requirements['min_max_execution_time']) {
$checks['max_execution_time'] = ['status' => 'ok', 'message' => (string)$maxExecTime];
} else {
$checks['max_execution_time'] = [
'status' => 'warning',
'message' => sprintf('%ds is below the recommended minimum %ds', $maxExecTime, $requirements['min_max_execution_time'])
];
}
// Calculate response time
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
// Set status code
http_response_code($healthy ? 200 : 503);
// This endpoint is unauthenticated, so expose only a coarse per-component status
// and never the diagnostic messages (they leak PHP_VERSION, exact missing
// extension names, and filesystem paths to anonymous callers).
$publicChecks = [];
foreach ($checks as $name => $check) {
$publicChecks[$name] = ['status' => $check['status']];
}
// Return response
echo json_encode([
'status' => $healthy ? 'healthy' : 'unhealthy',
'timestamp' => date('c'),
'response_time_ms' => $responseTime,
'checks' => $checks,
'checks' => $publicChecks,
'version' => '1.0.0'
], JSON_PRETTY_PRINT);
+123 -26
View File
@@ -15,6 +15,7 @@ try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication
if (session_status() === PHP_SESSION_NONE) {
@@ -52,6 +53,7 @@ try {
header('Content-Type: application/json');
$model = new RecurringTicketModel($conn);
$auditLog = new AuditLogModel($conn);
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
$action = isset($_GET['action']) ? $_GET['action'] : null;
@@ -70,6 +72,12 @@ try {
case 'POST':
if ($action === 'toggle' && $id) {
$result = $model->toggleActive($id);
if (!empty($result['success'])) {
$auditLog->log($currentUserId, 'update', 'recurring_ticket', (string)$id, [
'entity' => 'recurring_ticket',
'action' => 'toggle_active'
]);
}
echo json_encode($result);
} else {
$data = json_decode(file_get_contents('php://input'), true);
@@ -90,6 +98,14 @@ try {
$data['created_by'] = $currentUserId;
$result = $model->create($data);
if (!empty($result['success'])) {
$auditLog->log($currentUserId, 'create', 'recurring_ticket', (string)($result['recurring_id'] ?? ''), [
'title_template' => $data['title_template'],
'schedule_type' => $data['schedule_type'],
'schedule_day' => $data['schedule_day'] ?? null,
'schedule_time' => $data['schedule_time'] ?? '09:00'
]);
}
echo json_encode($result);
}
break;
@@ -106,16 +122,49 @@ try {
exit;
}
// Recalculate next run time if schedule changed
$nextRun = calculateNextRun(
$data['schedule_type'],
$data['schedule_day'] ?? null,
$data['schedule_time'] ?? '09:00'
);
$data['next_run_at'] = $nextRun;
$existing = $model->getById($id);
if (!$existing) {
echo json_encode(['success' => false, 'error' => 'Recurring ticket not found']);
exit;
}
$newDay = $data['schedule_day'] ?? null;
$newTime = $data['schedule_time'] ?? '09:00';
// Only the schedule fields affect when the next occurrence fires.
$scheduleChanged =
(string)$existing['schedule_type'] !== (string)$data['schedule_type']
|| (string)($existing['schedule_day'] ?? '') !== (string)($newDay ?? '')
|| substr((string)$existing['schedule_time'], 0, 5) !== substr((string)$newTime, 0, 5);
$existingNextFuture = !empty($existing['next_run_at'])
&& strtotime($existing['next_run_at']) > time();
// Recompute only when the schedule actually changed (or the stored
// next_run is already in the past). Editing an unrelated field (e.g.
// title) must NOT move next_run_at backwards past an occurrence that
// may already have fired, which would double-create a ticket.
if ($scheduleChanged || !$existingNextFuture) {
$data['next_run_at'] = calculateNextRun(
$data['schedule_type'],
$newDay,
$newTime
);
} else {
$data['next_run_at'] = $existing['next_run_at'];
}
$data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1;
$result = $model->update($id, $data);
if (!empty($result['success'])) {
$auditLog->log($currentUserId, 'update', 'recurring_ticket', (string)$id, [
'entity' => 'recurring_ticket',
'title_template' => $data['title_template'] ?? null,
'schedule_type' => $data['schedule_type'],
'schedule_day' => $newDay,
'schedule_time' => $newTime
]);
}
echo json_encode($result);
break;
@@ -125,7 +174,14 @@ try {
exit;
}
$toDelete = $model->getById($id);
$result = $model->delete($id);
if (!empty($result['success'])) {
$auditLog->log($currentUserId, 'delete', 'recurring_ticket', (string)$id, [
'entity' => 'recurring_ticket',
'title_template' => $toDelete['title_template'] ?? 'unknown'
]);
}
echo json_encode($result);
break;
@@ -139,36 +195,77 @@ try {
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
}
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime)
/**
* Compute the SOONEST FUTURE occurrence matching the schedule.
*
* Returns 'Y-m-d H:i:s' in the app-configured timezone. The current period is
* NOT skipped: a schedule whose time today/this-month is still in the future
* fires then, not one period later.
*
* @param string $scheduleType daily|weekly|monthly
* @param int|null $scheduleDay 1-7 (ISO, 1=Mon..7=Sun) weekly; 1-31 monthly
* @param string $scheduleTime HH:MM or HH:MM:SS
* @param DateTime|null $now Injected "now" for testing
*/
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime, ?DateTime $now = null)
{
$now = new DateTime();
$time = $scheduleTime ?: '09:00';
$tz = new DateTimeZone($GLOBALS['config']['TIMEZONE'] ?? date_default_timezone_get());
$now = $now ? $now : new DateTime('now', $tz);
$parts = explode(':', $scheduleTime ?: '09:00');
$hour = (int)($parts[0] ?? 9);
$minute = (int)($parts[1] ?? 0);
$second = (int)($parts[2] ?? 0);
$next = clone $now;
switch ($scheduleType) {
case 'daily':
$next = new DateTime('tomorrow ' . $time);
break;
case 'weekly':
$days = [1 => 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
$dayName = $days[(int)$scheduleDay] ?? 'Monday';
$next = new DateTime("next {$dayName} " . $time);
$targetDow = (int)$scheduleDay;
if ($targetDow < 1 || $targetDow > 7) {
$targetDow = 1;
}
$next->setTime($hour, $minute, $second);
$currentDow = (int)$next->format('N'); // 1=Mon .. 7=Sun
$daysAhead = ($targetDow - $currentDow + 7) % 7;
// Same weekday but the time already passed today -> next week.
if ($daysAhead === 0 && $next <= $now) {
$daysAhead = 7;
}
if ($daysAhead > 0) {
$next->modify("+{$daysAhead} day");
$next->setTime($hour, $minute, $second);
}
break;
case 'monthly':
$day = max(1, min(31, (int)$scheduleDay));
$next = new DateTime();
$next->modify('first day of next month');
// Clamp to last day of target month (handles Feb, 30-day months)
$daysInMonth = (int)$next->format('t');
$day = min($day, $daysInMonth);
$next->setDate((int)$next->format('Y'), (int)$next->format('m'), $day);
$parts = explode(':', $time . ':00'); // ensure at least H:M
$next->setTime((int)$parts[0], (int)$parts[1], 0);
// This month first, clamped to the month's length (e.g. day 31 -> Feb 28/29).
$daysInMonth = (int)$now->format('t');
$next->setDate((int)$now->format('Y'), (int)$now->format('n'), min($day, $daysInMonth));
$next->setTime($hour, $minute, $second);
if ($next <= $now) {
// Already passed this month -> first day of next month, then clamp.
$firstNext = clone $now;
$firstNext->modify('first day of next month');
$daysInMonth = (int)$firstNext->format('t');
$next->setDate(
(int)$firstNext->format('Y'),
(int)$firstNext->format('n'),
min($day, $daysInMonth)
);
$next->setTime($hour, $minute, $second);
}
break;
case 'daily':
default:
$next = new DateTime('tomorrow ' . $time);
$next->setTime($hour, $minute, $second);
if ($next <= $now) {
$next->modify('+1 day');
$next->setTime($hour, $minute, $second);
}
break;
}
return $next->format('Y-m-d H:i:s');
+33 -3
View File
@@ -14,6 +14,7 @@ RateLimitMiddleware::apply('api');
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication
if (session_status() === PHP_SESSION_NONE) {
@@ -48,6 +49,8 @@ try {
header('Content-Type: application/json');
$auditLog = new AuditLogModel($conn);
$currentUserId = $_SESSION['user']['user_id'];
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
@@ -110,7 +113,13 @@ try {
);
if ($stmt->execute()) {
echo json_encode(['success' => true, 'template_id' => $conn->insert_id]);
$newTemplateId = $conn->insert_id;
$auditLog->log($currentUserId, 'create', 'template', (string)$newTemplateId, [
'template_name' => $templateName,
'category' => $category,
'type' => $type
]);
echo json_encode(['success' => true, 'template_id' => $newTemplateId]);
} else {
error_log("Template creation failed: " . $stmt->error);
echo json_encode(['success' => false, 'error' => 'Failed to create template']);
@@ -161,7 +170,15 @@ try {
$id
);
echo json_encode(['success' => $stmt->execute()]);
$updated = $stmt->execute();
if ($updated) {
$auditLog->log($currentUserId, 'update', 'template', (string)$id, [
'template_name' => $templateName,
'category' => $category,
'type' => $type
]);
}
echo json_encode(['success' => $updated]);
$stmt->close();
break;
@@ -171,9 +188,22 @@ try {
exit;
}
// Capture the name before deletion for the audit record.
$nameStmt = $conn->prepare("SELECT template_name FROM ticket_templates WHERE template_id = ?");
$nameStmt->bind_param('i', $id);
$nameStmt->execute();
$delRow = $nameStmt->get_result()->fetch_assoc();
$nameStmt->close();
$stmt = $conn->prepare("DELETE FROM ticket_templates WHERE template_id = ?");
$stmt->bind_param('i', $id);
echo json_encode(['success' => $stmt->execute()]);
$deleted = $stmt->execute();
if ($deleted) {
$auditLog->log($currentUserId, 'delete', 'template', (string)$id, [
'template_name' => $delRow['template_name'] ?? 'unknown'
]);
}
echo json_encode(['success' => $deleted]);
$stmt->close();
break;
+32 -4
View File
@@ -175,6 +175,23 @@ $stmt->execute();
$mentionRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
// If the user owns/watches a ticket AND was @mentioned in the same comment, the
// comment query and the mention query both produce a row for it. Prefer the more
// specific mention and drop the duplicate comment notification for that comment.
$mentionCommentIds = [];
foreach ($mentionRows as $mr) {
$md = json_decode($mr['details'] ?? '{}', true) ?? [];
if (!empty($md['comment_id'])) {
$mentionCommentIds[(int)$md['comment_id']] = true;
}
}
if (!empty($mentionCommentIds)) {
$commentRows = array_filter(
$commentRows,
fn($cr) => !isset($mentionCommentIds[(int)($cr['entity_id'] ?? 0)])
);
}
// Merge, deduplicate by log_id, sort by created_at desc
$all = [];
$seen = [];
@@ -208,10 +225,21 @@ foreach ($all as $row) {
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
'mention' => "{$row['actor_name']} mentioned you on ticket #{$ticketId}",
'update' => (function () use ($row, $details, $ticketId) {
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}}
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
$to = $details['status']['to'] ?? ($details['new_value'] ?? '?');
return "{$row['actor_name']} changed status on #{$ticketId}: {$from}{$to}";
// Visibility changes log a flat {field, from, to} shape (api/update_ticket.php).
if (isset($details['field'], $details['from'], $details['to'])) {
return "{$row['actor_name']} changed {$details['field']} on #{$ticketId}: {$details['from']}{$details['to']}";
}
// Single/bulk field updates log a per-field delta, e.g.
// {"status": {"from": "Open", "to": "In Progress"}}. Only one field
// changed at a time is reported, in priority order below.
foreach (['status', 'priority', 'title', 'category', 'type', 'description'] as $field) {
if (isset($details[$field]['from'], $details[$field]['to'])) {
return "{$row['actor_name']} changed {$field} on #{$ticketId}: {$details[$field]['from']}{$details[$field]['to']}";
}
}
return "{$row['actor_name']} updated ticket #{$ticketId}";
})(),
default => "{$row['actor_name']} updated ticket #{$ticketId}",
};
+28 -6
View File
@@ -24,11 +24,13 @@ try {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
throw new Exception("Authentication required");
}
// Check admin privileges
if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) {
http_response_code(403);
throw new Exception("Admin privileges required");
}
@@ -51,12 +53,14 @@ try {
// Get request data
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
http_response_code(400);
throw new Exception("Invalid request data");
}
$keyId = (int)($input['key_id'] ?? 0);
if ($keyId <= 0) {
http_response_code(400);
throw new Exception("Valid key ID is required");
}
@@ -68,10 +72,12 @@ try {
$keyInfo = $apiKeyModel->getKeyById($keyId);
if (!$keyInfo) {
http_response_code(404);
throw new Exception("API key not found");
}
if (!$keyInfo['is_active']) {
http_response_code(409);
throw new Exception("API key is already revoked");
}
@@ -79,6 +85,7 @@ try {
$success = $apiKeyModel->revokeKey($keyId);
if (!$success) {
http_response_code(500);
throw new Exception("Failed to revoke API key");
}
@@ -103,11 +110,26 @@ try {
]);
} catch (Exception $e) {
ob_end_clean();
error_log("Revoke API key error: " . $e->getMessage());
header('Content-Type: application/json');
http_response_code(isset($conn) ? 400 : 500);
echo json_encode([
'success' => false,
'error' => 'An internal error occurred'
]);
// Preserve any specific status set before the throw (401/403/404/409/...);
// only fall back to 500 when nothing more specific was set.
$code = http_response_code();
if (!is_int($code) || $code < 400) {
$code = 500;
}
http_response_code($code);
if ($code >= 500) {
error_log("Revoke API key error: " . $e->getMessage());
echo json_encode([
'success' => false,
'error' => 'An internal error occurred'
]);
} else {
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
/**
* ticket_comment_api.php — Bearer-key endpoint to post a comment on a ticket.
*
* POST only. Requires 'read_write' scope.
*
* Identity = PER-KEY LABEL: the comment author (ticket_comments.user_name) is the
* API key's key_name and the linked user_id is the key's created_by.
*
* Body (JSON): {
* "ticket_id": "NNN" (required),
* "comment_text": "..." (required, non-empty),
* "markdown_enabled": bool (optional)
* }
* Response: {success:true, comment_id:...}
*/
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/middleware/ApiKeyAuth.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
try {
$conn = Database::getConnection();
} catch (Throwable $e) {
error_log('ticket_comment_api: DB connection failed: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Internal server error']);
exit;
}
$apiKeyAuth = new ApiKeyAuth($conn);
try {
$apiKeyAuth->authenticate();
} catch (Exception $e) {
// ApiKeyAuth already sent the 401 response.
exit;
}
// Posting a comment is a write — reject 'read' keys with 403 before any mutation.
$apiKeyAuth->requireScope('read_write');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed. Use POST.']);
exit;
}
$context = $apiKeyAuth->getKeyContext();
$keyName = $context['key_name'] ?? 'API';
$createdBy = ($context['created_by'] ?? null) !== null ? (int)$context['created_by'] : null;
$rawInput = file_get_contents('php://input');
$data = json_decode($rawInput, true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
exit;
}
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
if ($ticketId === '') {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ticket_id is required']);
exit;
}
$commentText = isset($data['comment_text']) ? trim((string)$data['comment_text']) : '';
if ($commentText === '') {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'comment_text is required']);
exit;
}
$markdownEnabled = !empty($data['markdown_enabled']);
// Validate the ticket exists.
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
// Post the comment under the key's label / owner.
$commentModel = new CommentModel($conn);
$result = $commentModel->addComment($ticketId, [
'user_name' => $keyName,
'comment_text' => $commentText,
'markdown_enabled' => $markdownEnabled,
], $createdBy);
if (empty($result['success'])) {
error_log('ticket_comment_api: addComment failed for ticket ' . $ticketId
. ': ' . ($result['error'] ?? 'unknown'));
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to add comment']);
exit;
}
$commentId = $result['comment_id'] ?? null;
// Audit trail (action 'comment' / entity 'comment' are both whitelisted).
$auditLog = new AuditLogModel($conn);
$auditLog->log($createdBy, 'comment', 'comment', (string)$commentId, [
'ticket_id' => $ticketId,
'key_name' => $keyName,
'via_api' => true,
]);
echo json_encode(['success' => true, 'comment_id' => $commentId]);
exit;
+21 -9
View File
@@ -27,10 +27,19 @@ register_shutdown_function(function () {
ini_set('display_errors', 0);
error_reporting(E_ALL);
// Custom error handler
// Custom error handler. Only genuine errors abort the request; notices,
// warnings and deprecations (e.g. new deprecations on a PHP upgrade) are
// logged but must not take the endpoint down with a 500.
set_error_handler(function ($errno, $errstr, $errfile, $errline) {
// Log detailed error server-side
// Respect the @-operator / error_reporting.
if (!(error_reporting() & $errno)) {
return false;
}
error_log("PHP Error in ticket_dependencies.php: $errstr in $errfile:$errline");
if (!in_array($errno, [E_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR, E_PARSE], true)) {
// Non-fatal: log and continue.
return true;
}
ob_end_clean();
http_response_code(500);
header('Content-Type: application/json');
@@ -80,6 +89,9 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
$userId = $_SESSION['user']['user_id'];
$currentUser = $_SESSION['user'];
$isAdmin = $currentUser['is_admin'] ?? false;
// users.groups is a comma-separated string; the dependency model expects an array.
$userGroups = array_values(array_filter(array_map('trim', explode(',', $currentUser['groups'] ?? ''))));
// CSRF Protection for POST/DELETE
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
@@ -121,14 +133,14 @@ try {
}
// Verify user can access this ticket
$ticket = $ticketModel->getTicketById((int)$ticketId);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
try {
$dependencies = $dependencyModel->getDependencies($ticketId);
$dependents = $dependencyModel->getDependentTickets($ticketId);
$dependencies = $dependencyModel->getDependencies($ticketId, $userId, $userGroups, $isAdmin);
$dependents = $dependencyModel->getDependentTickets($ticketId, $userId, $userGroups, $isAdmin);
} catch (Exception $e) {
error_log('Query error in ticket_dependencies.php GET: ' . $e->getMessage());
ResponseHelper::serverError('Failed to retrieve dependencies');
@@ -157,11 +169,11 @@ try {
}
// Verify user can access both tickets before creating dependency
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
$srcTicket = $ticketModel->getTicketById($ticketId);
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
$tgtTicket = $ticketModel->getTicketById((int)$dependsOnId);
$tgtTicket = $ticketModel->getTicketById($dependsOnId);
if (!$tgtTicket || !$ticketModel->canUserAccessTicket($tgtTicket, $currentUser)) {
ResponseHelper::notFound('Target ticket not found');
}
@@ -205,7 +217,7 @@ try {
}
// Verify user can access the source ticket
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
$srcTicket = $ticketModel->getTicketById($ticketId);
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
@@ -235,7 +247,7 @@ try {
ResponseHelper::notFound('Dependency not found');
}
$depTicket = $ticketModel->getTicketById((int)$depRow['ticket_id']);
$depTicket = $ticketModel->getTicketById($depRow['ticket_id']);
if (!$depTicket || !$ticketModel->canUserAccessTicket($depTicket, $currentUser)) {
ResponseHelper::forbidden('Access denied');
}
+203
View File
@@ -0,0 +1,203 @@
<?php
/**
* ticket_status_api.php — Bearer-key endpoint to change a ticket's status.
*
* POST only. Requires 'read_write' scope.
*
* Body (JSON): {
* "ticket_id": "NNN" (required),
* "status": "..." (required target status),
* "comment": "..." (optional; REQUIRED when the transition
* requires_comment),
* "markdown_enabled": bool (optional, applies to the comment)
* }
* Response: {success:true, ticket_id, status}
*
* Mirrors api/update_ticket.php: workflow validation, requires_comment
* enforcement, updateTicket (updated_by/updated_at + closed_at handling), Matrix
* status-change notification, and StatsModel cache invalidation. When a comment
* is supplied it is posted first (per-key label) so "close with reason" is one call.
*/
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/middleware/ApiKeyAuth.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/StatsModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
try {
$conn = Database::getConnection();
} catch (Throwable $e) {
error_log('ticket_status_api: DB connection failed: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Internal server error']);
exit;
}
$apiKeyAuth = new ApiKeyAuth($conn);
try {
$apiKeyAuth->authenticate();
} catch (Exception $e) {
// ApiKeyAuth already sent the 401 response.
exit;
}
// Changing status is a write — reject 'read' keys with 403 before any mutation.
$apiKeyAuth->requireScope('read_write');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed. Use POST.']);
exit;
}
$context = $apiKeyAuth->getKeyContext();
$keyName = $context['key_name'] ?? 'API';
$createdBy = ($context['created_by'] ?? null) !== null ? (int)$context['created_by'] : null;
$rawInput = file_get_contents('php://input');
$data = json_decode($rawInput, true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
exit;
}
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
if ($ticketId === '') {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ticket_id is required']);
exit;
}
$newStatus = isset($data['status']) ? trim((string)$data['status']) : '';
if ($newStatus === '') {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'status is required']);
exit;
}
$comment = isset($data['comment']) ? trim((string)$data['comment']) : '';
// Validate the ticket exists.
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
$currentStatus = (string)$ticket['status'];
// Validate the transition (API key is never admin).
$workflowModel = new WorkflowModel($conn);
if (!$workflowModel->isTransitionAllowed($currentStatus, $newStatus, false)) {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => 'Status transition not allowed: ' . $currentStatus . ' -> ' . $newStatus,
]);
exit;
}
// Enforce requires_comment transitions server-side.
if ($workflowModel->transitionRequiresComment($currentStatus, $newStatus) && $comment === '') {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => 'A comment is required for this status change',
'requires_comment' => true,
]);
exit;
}
// Post the comment first (per-key label) so a close-with-reason is one call.
if ($comment !== '') {
$commentModel = new CommentModel($conn);
$commentResult = $commentModel->addComment($ticketId, [
'user_name' => $keyName,
'comment_text' => $comment,
'markdown_enabled' => !empty($data['markdown_enabled']),
], $createdBy);
if (empty($commentResult['success'])) {
error_log('ticket_status_api: addComment failed for ticket ' . $ticketId
. ': ' . ($commentResult['error'] ?? 'unknown'));
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to add comment']);
exit;
}
}
// Apply the status change. updateTicket sets updated_by/updated_at and handles
// closed_at (set on close, cleared on reopen) via its own SQL.
$updateData = [
'ticket_id' => $ticketId,
'title' => $ticket['title'],
'description' => $ticket['description'],
'category' => $ticket['category'],
'type' => $ticket['type'],
'status' => $newStatus,
'priority' => (int)$ticket['priority'],
];
$updateResult = $ticketModel->updateTicket($updateData, $createdBy);
if (empty($updateResult['success'])) {
error_log('ticket_status_api: updateTicket failed for ticket ' . $ticketId
. ': ' . ($updateResult['error'] ?? 'unknown'));
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to update ticket status']);
exit;
}
// Notify, audit, and refresh stats only when the status actually changed.
if ($currentStatus !== $newStatus) {
NotificationHelper::sendStatusChangeNotification(
$ticketId,
$currentStatus,
$newStatus,
(string)$ticket['title'],
$keyName
);
NotificationHelper::notifyWatchers(
$conn,
$ticketId,
(string)$ticket['title'],
'status_changed',
['old_status' => $currentStatus, 'new_status' => $newStatus, 'changed_by' => $keyName],
$createdBy,
$ticket['visibility'] ?? 'public'
);
// Audit trail (action 'update' / entity 'ticket' are both whitelisted).
$auditLog = new AuditLogModel($conn);
$auditLog->log($createdBy, 'update', 'ticket', $ticketId, [
'status' => ['from' => $currentStatus, 'to' => $newStatus],
'key_name' => $keyName,
'via_api' => true,
]);
// Status change is a ticket-state change — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
}
echo json_encode([
'success' => true,
'ticket_id' => $ticketId,
'status' => $newStatus,
]);
exit;
+139
View File
@@ -0,0 +1,139 @@
<?php
/**
* tickets_api.php — Bearer-key read endpoint (list/triage + read-one).
*
* GET only. Requires 'read' scope (a 'read_write' key also satisfies it).
* Acts as a trusted automation/server credential: reads return the full queue
* (no per-user visibility filtering).
*
* GET ?ticket_id=NNN -> {success, ticket, comments}
* GET ?status=&priority=&host= -> {success, tickets, page, total, pages}
* &page=&limit=
*/
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
// Rate limiting (same pattern as the other Bearer API endpoints)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/middleware/ApiKeyAuth.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
try {
$conn = Database::getConnection();
} catch (Throwable $e) {
error_log('tickets_api: DB connection failed: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Internal server error']);
exit;
}
$apiKeyAuth = new ApiKeyAuth($conn);
try {
$apiKeyAuth->authenticate();
} catch (Exception $e) {
// ApiKeyAuth already sent the 401 response.
exit;
}
// Reads only need the 'read' scope.
$apiKeyAuth->requireScope('read');
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed. Use GET.']);
exit;
}
$ticketModel = new TicketModel($conn);
// ── READ ONE ──────────────────────────────────────────────────────────────
if (isset($_GET['ticket_id']) && trim((string)$_GET['ticket_id']) !== '') {
$ticketId = trim((string)$_GET['ticket_id']);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
// Flat list of comments (newest first) — same fetch the ticket view uses.
$commentModel = new CommentModel($conn);
$comments = $commentModel->getCommentsByTicketId($ticketId, false);
echo json_encode([
'success' => true,
'ticket' => $ticket,
'comments' => $comments,
]);
exit;
}
// ── LIST / TRIAGE ───────────────────────────────────────────────────────────
$status = (isset($_GET['status']) && trim((string)$_GET['status']) !== '')
? trim((string)$_GET['status'])
: 'Open';
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
if ($page < 1) {
$page = 1;
}
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 25;
if ($limit < 1) {
$limit = 25;
}
if ($limit > 100) {
$limit = 100; // cap
}
$filters = [];
if (isset($_GET['priority']) && trim((string)$_GET['priority']) !== '') {
$priority = (int)$_GET['priority'];
if ($priority >= 1 && $priority <= 5) {
// Exact-priority match via the min/max range filter.
$filters['priority_min'] = $priority;
$filters['priority_max'] = $priority;
}
}
// hwmon puts the host in the title (e.g. "[hostname] ..."), so a host filter is a
// title substring match — served by getAllTickets's `search` param (title search).
$search = null;
if (isset($_GET['host']) && trim((string)$_GET['host']) !== '') {
$search = trim((string)$_GET['host']);
}
// user = null => getAllTickets skips visibility filtering and returns the full
// queue (this is a trusted server credential, not an end user).
$result = $ticketModel->getAllTickets(
$page,
$limit,
$status,
'ticket_id',
'desc',
null,
null,
$search,
$filters,
null
);
echo json_encode([
'success' => true,
'tickets' => $result['tickets'],
'page' => $result['current_page'],
'total' => $result['total'],
'pages' => $result['pages'],
]);
exit;
+17 -5
View File
@@ -27,12 +27,16 @@ try {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
ob_end_clean();
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// CSRF Protection
// CSRF Protection for all state-changing methods (any non-GET/HEAD request)
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT') {
if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
@@ -53,7 +57,11 @@ try {
$data = json_decode(file_get_contents('php://input'), true);
if (!$data || !isset($data['comment_id']) || !isset($data['comment_text'])) {
throw new Exception("Missing required fields: comment_id, comment_text");
ob_end_clean();
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Missing required fields: comment_id, comment_text']);
exit;
}
$commentId = (int)$data['comment_id'];
@@ -61,7 +69,11 @@ try {
$markdownEnabled = isset($data['markdown_enabled']) && $data['markdown_enabled'];
if (empty($commentText)) {
throw new Exception("Comment text cannot be empty");
ob_end_clean();
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Comment text cannot be empty']);
exit;
}
// Initialize models
+62 -8
View File
@@ -34,7 +34,11 @@ try {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
ob_end_clean();
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// CSRF Protection
@@ -44,9 +48,14 @@ try {
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode([
'success' => false,
'error' => 'Invalid CSRF token',
'csrf_token' => CsrfMiddleware::getToken()
]);
exit;
}
$GLOBALS['newCsrfToken'] = CsrfMiddleware::rotateToken();
}
$currentUser = $_SESSION['user'];
@@ -115,7 +124,8 @@ try {
if (empty($updateData['title'])) {
return [
'success' => false,
'error' => 'Title cannot be empty'
'error' => 'Title cannot be empty',
'http_status' => 400
];
}
@@ -123,7 +133,8 @@ try {
if ($updateData['priority'] < 1 || $updateData['priority'] > 5) {
return [
'success' => false,
'error' => 'Priority must be between 1 and 5'
'error' => 'Priority must be between 1 and 5',
'http_status' => 400
];
}
@@ -137,11 +148,32 @@ try {
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
}
// Authorization: only an admin or the ticket's creator may change
// visibility. Enforce only when the requested visibility actually
// differs so ordinary edits that re-send the same value aren't blocked.
$currentVisibility = $currentTicket['visibility'] ?? 'public';
$currentGroups = $currentTicket['visibility_groups'] ?? null;
$groupsProvided = array_key_exists('visibility_groups', $data);
$visibilityChanged = ($data['visibility'] !== $currentVisibility)
|| ($groupsProvided && (string)$visibilityGroups !== (string)$currentGroups);
if ($visibilityChanged) {
$isCreator = $this->userId !== null
&& (int)($currentTicket['created_by'] ?? 0) === (int)$this->userId;
if (!$this->isAdmin && !$isCreator) {
return [
'success' => false,
'error' => 'You do not have permission to change ticket visibility',
'http_status' => 403
];
}
}
// Internal visibility requires at least one group
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
return [
'success' => false,
'error' => 'Internal visibility requires at least one group to be specified'
'error' => 'Internal visibility requires at least one group to be specified',
'http_status' => 400
];
}
}
@@ -160,6 +192,19 @@ try {
'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status']
];
}
// Enforce requires_comment transitions server-side.
if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) {
$comment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
if ($comment === '') {
return [
'success' => false,
'error' => 'A comment is required for this status change',
'requires_comment' => true,
'http_status' => 400
];
}
}
}
// Update ticket with user tracking and optional optimistic locking
@@ -239,7 +284,8 @@ try {
'status' => $updateData['status'],
'priority' => $updateData['priority'],
'updated_at' => date('Y-m-d H:i:s'),
'message' => 'Ticket updated successfully'
'message' => 'Ticket updated successfully',
'csrf_token' => $GLOBALS['newCsrfToken'] ?? null
];
}
}
@@ -257,11 +303,19 @@ try {
$data = json_decode($input, true);
if (!$data) {
throw new Exception("Invalid JSON data received: " . $input);
ob_end_clean();
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid JSON data received']);
exit;
}
if (!isset($data['ticket_id'])) {
throw new Exception("Missing ticket_id parameter");
ob_end_clean();
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Missing ticket_id parameter']);
exit;
}
$ticketId = trim((string)$data['ticket_id']);
+89 -1
View File
@@ -29,6 +29,73 @@ require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
header('Content-Type: application/json');
/**
* Strip EXIF/metadata (including GPS) from an image file in place by
* decoding and re-encoding it via GD, which drops metadata chunks that
* aren't part of the pixel data. Best-effort: leaves the file untouched on
* any failure (corrupt image, unsupported format, GD unavailable) rather
* than blocking the upload — original bytes are what would have been stored
* anyway before this existed.
*
* download_attachment.php streams attachments back byte-for-byte to any user
* with ticket visibility, so an unstripped phone photo's embedded GPS data
* would otherwise leak a data center/office's physical location even on a
* Confidential-visibility ticket.
*/
function stripImageMetadata(string $path, string $mimeType): void
{
if (!extension_loaded('gd')) {
return;
}
// Guard against a decompression-bomb-style crafted image (small file,
// huge decoded pixel buffer) exhausting memory during decode.
$dims = @getimagesize($path);
if ($dims === false) {
return;
}
[$width, $height] = $dims;
if ($width * $height > 40_000_000) { // ~40 MP cap
return;
}
$loaders = [
'image/jpeg' => 'imagecreatefromjpeg',
'image/png' => 'imagecreatefrompng',
'image/gif' => 'imagecreatefromgif',
'image/webp' => 'imagecreatefromwebp',
];
$loader = $loaders[$mimeType] ?? null;
if ($loader === null || !function_exists($loader)) {
return;
}
$image = @$loader($path);
if ($image === false) {
return;
}
// Preserve transparency for formats that support it.
imagesavealpha($image, true);
imagealphablending($image, false);
$tmpPath = $path . '.tmp';
$saved = match ($mimeType) {
'image/jpeg' => imagejpeg($image, $tmpPath, 90),
'image/png' => imagepng($image, $tmpPath, 6),
'image/gif' => imagegif($image, $tmpPath),
'image/webp' => imagewebp($image, $tmpPath, 90),
default => false,
};
imagedestroy($image);
if ($saved && file_exists($tmpPath)) {
rename($tmpPath, $path);
} elseif (file_exists($tmpPath)) {
unlink($tmpPath);
}
}
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
ResponseHelper::unauthorized();
@@ -127,6 +194,23 @@ if ($file['size'] > $maxSize) {
ResponseHelper::error('File size exceeds maximum allowed (' . AttachmentModel::formatFileSize($maxSize) . ')');
}
// Check per-ticket attachment count/storage quota — bounds an authenticated
// low-privilege user slowly filling the uploads/ disk across many tickets,
// which was previously bounded only by the request-rate limiter, not volume.
$attachmentModel = new AttachmentModel($conn);
$maxAttachments = $GLOBALS['config']['MAX_ATTACHMENTS_PER_TICKET'] ?? 50;
if ($attachmentModel->getAttachmentCount($ticketId) >= $maxAttachments) {
ResponseHelper::error("This ticket already has the maximum of {$maxAttachments} attachments");
}
$maxTotalSize = $GLOBALS['config']['MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET'] ?? 104857600;
if ($attachmentModel->getTotalSizeForTicket($ticketId) + $file['size'] > $maxTotalSize) {
ResponseHelper::error(
'This upload would exceed the ticket\'s total attachment size limit of '
. AttachmentModel::formatFileSize($maxTotalSize)
);
}
// Get MIME type
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
@@ -184,6 +268,11 @@ if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
ResponseHelper::serverError('Failed to move uploaded file');
}
// Strip EXIF/GPS metadata from image uploads before it's ever served back
if (str_starts_with($mimeType, 'image/')) {
stripImageMetadata($targetPath, $mimeType);
}
// Sanitize original filename
$originalFilename = basename($file['name']);
$originalFilename = preg_replace('/[^\w\s\-\.]/', '', $originalFilename);
@@ -193,7 +282,6 @@ if (empty($originalFilename)) {
// Save to database
try {
$attachmentModel = new AttachmentModel($conn);
$attachmentId = $attachmentModel->addAttachment(
$ticketId,
$uniqueFilename,
+14 -2
View File
@@ -10,12 +10,13 @@
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$ticketId = isset($_GET['ticket_id'])
? (int)$_GET['ticket_id']
: (isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0);
: (int)($data['ticket_id'] ?? 0);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$ticketId = (int)($data['ticket_id'] ?? 0);
$action = $data['action'] ?? '';
@@ -78,6 +79,17 @@ if ($ticketId <= 0) {
exit;
}
// Enforce ticket visibility before returning watch state / watcher names, so a
// restricted ticket's watcher list and count aren't disclosed (the POST path
// already checks this).
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
$watchingStmt = $conn->prepare(
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
);
+22
View File
@@ -66,6 +66,7 @@
--accent-green-bright: #33FFAA;
--accent-green-dim: rgba(0,255,136,0.10);
--accent-green-border: rgba(0,255,136,0.22);
--shadow-color: rgba(0,0,0,0.5);
/* --- Error / Critical --- */
--accent-red: #FF2D55;
@@ -3640,6 +3641,8 @@ html[data-theme="light"] {
--accent-red-dim: rgba(181,0,31,0.10);
--accent-amber-dim: rgba(138,90,0,0.10);
--accent-cyan-border: rgba(0,98,184,0.28);
--accent-green-border: rgba(0,109,53,0.28);
--shadow-color: rgba(50,80,130,0.18);
/* — Glows become subtle drop shadows in light mode — */
--glow-orange: 0 0 0 1px rgba(196,78,0,0.25), 0 1px 6px rgba(196,78,0,0.18);
@@ -3737,6 +3740,25 @@ html[data-theme="light"] .lt-textarea:focus-visible {
border-color: var(--accent-cyan);
box-shadow: var(--box-glow-cyan);
}
/* Native <select> popup in light mode.
`.lt-select` sets `color-scheme: dark` on the element itself, which beats the
`color-scheme: light` declared on <html>, so the browser drew the dropdown with
dark chrome even in light mode. Reset it per element, and re-tint the option
list, which is otherwise hardcoded to #0d1117 for the dark theme. */
html[data-theme="light"] .lt-select { color-scheme: light; }
html[data-theme="light"] .lt-select option,
html[data-theme="light"] select option {
background: var(--bg-input);
color: var(--text-primary);
}
html[data-theme="light"] .lt-select option:hover,
html[data-theme="light"] .lt-select option:focus,
html[data-theme="light"] .lt-select option:checked,
html[data-theme="light"] select option:checked {
background: var(--accent-orange-dim);
color: var(--accent-orange);
}
html[data-theme="light"] .lt-label { color: var(--text-muted); }
/* — Buttons — */
+9 -4
View File
@@ -338,17 +338,22 @@ kbd {
}
/* ── Ticket preview popup ────────────────────────────────────── */
/* --lt-surface is not defined anywhere, so the background always fell through to
the hardcoded #0a0e14 — a near-black panel in light mode, with no colour set at
all, so the inherited near-black body text was invisible on it. These tokens
are redefined for light mode in base.css. */
.ticket-preview-popup {
position: fixed;
z-index: 9999;
background: var(--lt-surface, #0a0e14);
border: 1px solid rgba(0, 255, 65, 0.4);
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--accent-green-border);
padding: 0.75rem;
min-width: 280px;
max-width: 360px;
font-size: 0.75rem;
pointer-events: auto;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
box-shadow: 0 4px 20px var(--shadow-color);
}
.ticket-preview-popup .preview-header {
display: flex;
@@ -356,7 +361,7 @@ kbd {
align-items: center;
margin-bottom: 0.4rem;
}
.ticket-preview-popup .preview-id { color: var(--lt-cyan, #00ffff); font-weight: 700; }
.ticket-preview-popup .preview-id { color: var(--accent-cyan); font-weight: 700; }
.ticket-preview-popup .preview-title { font-weight: 600; margin-bottom: 0.4rem; }
.ticket-preview-popup .preview-meta { opacity: 0.7; display: flex; flex-direction: column; gap: 0.1rem; }
.ticket-preview-popup .preview-footer { margin-top: 0.4rem; opacity: 0.5; font-size: 0.65rem; }
+12 -4
View File
@@ -54,10 +54,18 @@ body.edit-mode .editable-metadata {
text-transform: uppercase;
letter-spacing: 0.05em;
}
.lt-status-select.lt-status-open { color: var(--lt-success, #00ff41); border-color: var(--lt-success, #00ff41); }
.lt-status-select.lt-status-pending { color: var(--lt-amber, #ffb000); border-color: var(--lt-amber, #ffb000); }
.lt-status-select.lt-status-in-progress { color: var(--lt-cyan, #00ffff); border-color: var(--lt-cyan, #00ffff); }
.lt-status-select.lt-status-closed { color: var(--lt-danger, #ff4d4d); border-color: var(--lt-danger, #ff4d4d); }
/* --lt-success / --lt-amber / --lt-cyan / --lt-danger are not defined anywhere,
so these always fell through to the hardcoded neon fallbacks — unreadable on
the light theme's white input background. The --accent-* tokens carry the same
hues and are redefined for light mode in base.css.
The leading .lt-select is needed for specificity: base.css's
`html[data-theme="light"] .lt-select` (0,2,1) would otherwise outrank a plain
two-class selector and repaint every status the same near-black. */
.lt-select.lt-status-select.lt-status-open { color: var(--accent-green); border-color: var(--accent-green); }
.lt-select.lt-status-select.lt-status-pending { color: var(--accent-amber); border-color: var(--accent-amber); }
.lt-select.lt-status-select.lt-status-in-progress { color: var(--accent-cyan); border-color: var(--accent-cyan); }
.lt-select.lt-status-select.lt-status-closed { color: var(--accent-red); border-color: var(--accent-red); }
/* ── Ticket meta KV grid ─────────────────────────────────────── */
.ticket-meta-grid {
+17 -8
View File
@@ -87,11 +87,17 @@ function performAdvancedSearch(event) {
params.set('search', searchText);
}
// Date ranges
const createdFrom = document.getElementById('adv-created-from').value;
const createdTo = document.getElementById('adv-created-to').value;
const updatedFrom = document.getElementById('adv-updated-from').value;
const updatedTo = document.getElementById('adv-updated-to').value;
// Date ranges — swap if the user entered an end date before the start date
let createdFrom = document.getElementById('adv-created-from').value;
let createdTo = document.getElementById('adv-created-to').value;
if (createdFrom && createdTo && createdFrom > createdTo) {
[createdFrom, createdTo] = [createdTo, createdFrom];
}
let updatedFrom = document.getElementById('adv-updated-from').value;
let updatedTo = document.getElementById('adv-updated-to').value;
if (updatedFrom && updatedTo && updatedFrom > updatedTo) {
[updatedFrom, updatedTo] = [updatedTo, updatedFrom];
}
if (createdFrom) params.set('created_from', createdFrom);
if (createdTo) params.set('created_to', createdTo);
@@ -105,9 +111,12 @@ function performAdvancedSearch(event) {
params.set('status', selectedStatuses.join(','));
}
// Priority range
const priorityMin = document.getElementById('adv-priority-min').value;
const priorityMax = document.getElementById('adv-priority-max').value;
// Priority range — swap if min > max so the range is always satisfiable
let priorityMin = document.getElementById('adv-priority-min').value;
let priorityMax = document.getElementById('adv-priority-max').value;
if (priorityMin && priorityMax && Number(priorityMin) > Number(priorityMax)) {
[priorityMin, priorityMax] = [priorityMax, priorityMin];
}
if (priorityMin) params.set('priority_min', priorityMin);
if (priorityMax) params.set('priority_max', priorityMax);
+205 -6
View File
@@ -241,6 +241,11 @@
trigger.focus();
}
}
// Announce the close so whoever opened the modal can undo optimistic UI or
// clean up a dynamically-inserted overlay. A modal can be dismissed four
// ways — the ✕ button, a Cancel button, a backdrop click, and Escape — and
// the last two are handled globally here, so button-only listeners miss them.
el.dispatchEvent(new CustomEvent('lt:modalclose', { bubbles: true }));
}
function closeAllModals() {
@@ -468,7 +473,15 @@
try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.message); }
let data;
try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; }
if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status);
// Resync CSRF token from any response body that carries a fresh one
// (bootstrap rotates on success and returns the current token on rejection).
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
if (!resp.ok) {
const err = new Error(data.error || data.message || 'HTTP ' + resp.status);
err.data = data;
err.status = resp.status;
throw err;
}
return data;
}
@@ -2004,6 +2017,7 @@
let _focusedIdx = -1;
let _items = [];
let _debTimer = null;
let _searchSeq = 0;
function _render(items, query) {
_items = items.slice(0, maxResults);
@@ -2028,16 +2042,21 @@
}
async function _search(query) {
// Sequence guard: only the latest query is allowed to render, so a slow
// earlier async source() cannot overwrite a newer query's results.
const seq = ++_searchSeq;
dropdown.innerHTML = '<div class="lt-typeahead-loading">Searching…</div>';
dropdown.classList.add('is-open');
inputEl.setAttribute('aria-busy', 'true');
try {
const results = typeof source === 'function' ? await source(query) : source.filter(i => i.label.toLowerCase().includes(query.toLowerCase()));
if (seq !== _searchSeq) return;
_render(results, query);
} catch(e) {
if (seq !== _searchSeq) return;
dropdown.innerHTML = '<div class="lt-typeahead-empty">Error loading results</div>';
} finally {
inputEl.setAttribute('aria-busy', 'false');
if (seq === _searchSeq) inputEl.setAttribute('aria-busy', 'false');
}
}
@@ -2456,6 +2475,101 @@
list.addEventListener('drop', e => { e.preventDefault(); });
// Touch fallback — iOS Safari doesn't implement HTML5 drag-and-drop on
// arbitrary elements at all, and mobile Chrome's support is poor, so
// kanban drag was effectively unusable via touch without this. Touch
// events for a given touch point are always dispatched to the element
// touchstart fired on (per spec), so per-list local state here is safe;
// cross-list moves are resolved via elementFromPoint against the live
// finger position, same as dragover does via e.target above.
const DRAG_THRESHOLD = 8; // px of movement before a touch starts a drag
let _touchItem = null, _touchDragging = false;
let _touchStartX = 0, _touchStartY = 0, _touchOffsetX = 0, _touchOffsetY = 0;
function _touchTargetList(x, y) {
const el = document.elementFromPoint(x, y);
const found = el ? el.closest('[data-sortable-group]') : null;
return found && (found === list || _sameGroup(found)) ? found : null;
}
list.addEventListener('touchstart', e => {
const item = e.target.closest('[data-sortable-item]');
if (!item || !list.contains(item)) return;
if (handle && !e.target.closest(handle)) return;
const t = e.touches[0];
_touchItem = item;
_touchDragging = false;
_touchStartX = t.clientX;
_touchStartY = t.clientY;
}, { passive: true });
// touchmove/touchend/touchcancel are registered on document, not list:
// once the dragged item is reparented to document.body below, it's no
// longer a descendant of list, so events targeting it (touch events
// keep targeting their touchstart element for the whole gesture) would
// stop bubbling to a listener on list.
document.addEventListener('touchmove', e => {
if (!_touchItem) return;
const t = e.touches[0];
if (!_touchDragging) {
if (Math.abs(t.clientX - _touchStartX) < DRAG_THRESHOLD && Math.abs(t.clientY - _touchStartY) < DRAG_THRESHOLD) return;
// Drag intent confirmed — take over from here, blocking page scroll.
_touchDragging = true;
_srtDragging = _touchItem;
_srtSrcList = list;
_srtPlaceholder = _makePlaceholder(_touchItem);
_touchItem.classList.add('is-dragging');
const rect = _touchItem.getBoundingClientRect();
_touchOffsetX = _touchStartX - rect.left;
_touchOffsetY = _touchStartY - rect.top;
_touchItem.parentNode.insertBefore(_srtPlaceholder, _touchItem);
_touchItem.style.position = 'fixed';
_touchItem.style.zIndex = '1000';
_touchItem.style.width = rect.width + 'px';
_touchItem.style.pointerEvents = 'none';
document.body.appendChild(_touchItem); // avoid clipping by an overflow:hidden ancestor
}
e.preventDefault();
_touchItem.style.left = (t.clientX - _touchOffsetX) + 'px';
_touchItem.style.top = (t.clientY - _touchOffsetY) + 'px';
const targetList = _touchTargetList(t.clientX, t.clientY);
if (!targetList) return;
const overEl = document.elementFromPoint(t.clientX, t.clientY);
const over = overEl ? overEl.closest('[data-sortable-item]') : null;
if (over && over !== _srtDragging && targetList.contains(over)) {
const rect = over.getBoundingClientRect();
targetList.insertBefore(_srtPlaceholder, t.clientY < rect.top + rect.height / 2 ? over : over.nextSibling);
} else if (!targetList.contains(_srtPlaceholder)) {
targetList.appendChild(_srtPlaceholder);
}
}, { passive: false });
function _touchEnd() {
if (_touchDragging && _srtDragging) {
_srtDragging.classList.remove('is-dragging');
_srtDragging.style.position = '';
_srtDragging.style.zIndex = '';
_srtDragging.style.width = '';
_srtDragging.style.pointerEvents = '';
_srtDragging.style.left = '';
_srtDragging.style.top = '';
if (_srtPlaceholder && _srtPlaceholder.parentNode) {
_srtPlaceholder.parentNode.insertBefore(_srtDragging, _srtPlaceholder);
_srtPlaceholder.remove();
}
if (onSort) onSort(_getItems(), _srtDragging);
bus.emit('sortable:change', { list, items: _getItems(), moved: _srtDragging });
}
_touchItem = null; _touchDragging = false;
_srtDragging = null; _srtPlaceholder = null; _srtSrcList = null;
}
document.addEventListener('touchend', _touchEnd);
document.addEventListener('touchcancel', _touchEnd);
return {
refresh() { Array.from(list.children).forEach(child => { if (!child.hasAttribute('data-sortable-item')) _mark(child); }); },
getOrder: () => _getItems().map(el => el.dataset.id || el.textContent.trim()),
@@ -2704,7 +2818,15 @@
}
let data;
try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; }
if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status);
// Resync CSRF token from any response body that carries a fresh one
// (bootstrap rotates on success and returns the current token on rejection).
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
if (!resp.ok) {
const err = new Error(data.error || data.message || 'HTTP ' + resp.status);
err.data = data;
err.status = resp.status;
throw err;
}
return data;
}
api.get = url => _apiFetchAuth('GET', url);
@@ -2713,6 +2835,82 @@
api.patch = (u, b) => _apiFetchAuth('PATCH', u, b);
api.delete = (u, b) => _apiFetchAuth('DELETE', u, b);
/* ================================================================
TICKET STATUS CHANGE (comment-aware)
lt.ticketStatus.submit(ticketId, newStatus, { comment? }) → Promise<data>
Posts /api/update_ticket.php. If the server rejects with
requires_comment, opens a comment modal, persists the comment via
/api/add_comment.php, then retries the update once WITH the comment.
Rejects with err.cancelled === true if the user cancels the modal.
================================================================ */
function _statusCommentModal(newStatus) {
return new Promise(resolve => {
const modalId = 'ltStatusCommentModal' + Date.now();
const safeStatus = escHtml(newStatus);
document.body.insertAdjacentHTML('beforeend',
'<div class="lt-modal-overlay" id="' + modalId + '" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="' + modalId + '_title">' +
'<div class="lt-modal lt-modal-sm">' +
'<div class="lt-modal-header" style="color:var(--terminal-amber)">' +
'<span class="lt-modal-title" id="' + modalId + '_title">[ ! ] Change Status to ' + safeStatus + '</span>' +
'<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>' +
'</div>' +
'<div class="lt-modal-body">' +
'<p class="lt-text-sm lt-text-muted" style="margin-bottom:0.6rem">A comment is required when changing status to <strong>' + safeStatus + '</strong>. Enter your reason below.</p>' +
'<textarea id="' + modalId + '_comment" class="lt-input lt-w-full" rows="3" placeholder="Reason for status change…" style="resize:vertical;font-family:inherit;font-size:0.8rem" aria-label="Required comment for status change"></textarea>' +
'</div>' +
'<div class="lt-modal-footer">' +
'<button class="lt-btn lt-btn-primary" id="' + modalId + '_confirm">CONFIRM CHANGE</button>' +
'<button class="lt-btn lt-btn-ghost" id="' + modalId + '_cancel">CANCEL</button>' +
'</div>' +
'</div>' +
'</div>');
const modalEl = document.getElementById(modalId);
openModal(modalId);
let done = false;
const finish = (value) => {
if (done) return;
done = true;
closeModal(modalId);
setTimeout(() => { if (modalEl && modalEl.parentNode) modalEl.remove(); }, 300);
resolve(value);
};
// Any dismissal counts as "no comment given", including a backdrop click or
// Escape, which close the overlay through the global handlers above.
modalEl.addEventListener('lt:modalclose', () => finish(null));
modalEl.querySelector('[data-modal-close]').addEventListener('click', () => finish(null));
document.getElementById(modalId + '_cancel').addEventListener('click', () => finish(null));
document.getElementById(modalId + '_confirm').addEventListener('click', () => {
const ta = document.getElementById(modalId + '_comment');
const comment = ta ? ta.value.trim() : '';
if (!comment) { if (ta) ta.focus(); toast.warning('Please enter a reason for this status change.'); return; }
finish(comment);
});
setTimeout(() => { const ta = document.getElementById(modalId + '_comment'); if (ta) ta.focus(); }, 100);
});
}
const ticketStatus = {
submit(ticketId, newStatus, opts) {
opts = opts || {};
const id = String(ticketId);
const payload = { ticket_id: id, status: newStatus };
if (opts.comment) payload.comment = opts.comment;
return api.post('/api/update_ticket.php', payload).catch(err => {
if (!(err && err.data && err.data.requires_comment)) throw err;
return _statusCommentModal(newStatus).then(comment => {
if (!comment) {
const cancelErr = new Error('Status change cancelled');
cancelErr.cancelled = true;
throw cancelErr;
}
// Persist the comment, then retry the status change with it included.
return api.post('/api/add_comment.php', { ticket_id: id, comment_text: comment })
.then(() => api.post('/api/update_ticket.php', { ticket_id: id, status: newStatus, comment: comment }));
});
});
},
};
/* ================================================================
MODULE 54 — MARKDOWN RENDERER
lt.markdown.render(mdString) → HTML string (sanitized)
@@ -2722,9 +2920,9 @@
================================================================ */
const markdown = {
render(md) {
// Delegate to window.marked if available
if (global.marked) return global.marked.parse(md);
if (global.markdownit) return global.markdownit().render(md);
// Always use the built-in XSS-safe micro-renderer. Do NOT delegate to
// window.marked / window.markdownit: their raw HTML output is not sanitized
// here, so delegating would enable stored XSS if such a lib were ever loaded.
// Micro-renderer: covers headings, bold, italic, code, links, lists, blockquote, hr
let html = escHtml(md)
// Fenced code blocks
@@ -2943,6 +3141,7 @@
lightbox,
auth,
markdown,
ticketStatus,
pagination,
sidebarSubmenus: { init: initSidebarSubmenus },
};
+174 -126
View File
@@ -157,6 +157,12 @@ document.addEventListener('DOMContentLoaded', function() {
case 'close-bulk-status-modal':
closeBulkStatusModal();
break;
case 'perform-bulk-close':
performBulkCloseAction();
break;
case 'close-bulk-close-modal':
closeBulkCloseModal();
break;
case 'perform-bulk-delete':
performBulkDelete();
break;
@@ -291,8 +297,12 @@ function clearAllFilters() {
params.delete('type');
params.delete('assigned_to');
params.delete('search');
params.delete('date_from');
params.delete('date_to');
params.delete('created_from');
params.delete('created_to');
params.delete('updated_from');
params.delete('updated_to');
params.delete('closed_from');
params.delete('closed_to');
params.delete('page');
// Keep sort parameters
@@ -375,73 +385,6 @@ function initSettingsModal() {
}
}
function sortTable(table, column) {
const headers = table.querySelectorAll('th');
headers.forEach(header => {
header.classList.remove('sort-asc', 'sort-desc');
});
const rows = Array.from(table.querySelectorAll('tbody tr'));
const currentDirection = table.dataset.sortColumn == column
? (table.dataset.sortDirection === 'asc' ? 'desc' : 'asc')
: 'asc';
table.dataset.sortColumn = column;
table.dataset.sortDirection = currentDirection;
rows.sort((a, b) => {
const aValue = a.children[column].textContent.trim();
const bValue = b.children[column].textContent.trim();
// Check if this is a date column — prefer data-ts attribute over text (which may be relative)
const headerText = headers[column].textContent.toLowerCase();
if (headerText === 'created' || headerText === 'updated') {
const cellA = a.children[column];
const cellB = b.children[column];
const dateA = new Date(cellA.dataset.ts || aValue);
const dateB = new Date(cellB.dataset.ts || bValue);
return currentDirection === 'asc' ? dateA - dateB : dateB - dateA;
}
// Special handling for "Assigned To" column
if (headerText === 'assigned to') {
const aUnassigned = aValue === 'Unassigned';
const bUnassigned = bValue === 'Unassigned';
// Both unassigned - equal
if (aUnassigned && bUnassigned) return 0;
// Put unassigned at the end regardless of sort direction
if (aUnassigned) return 1;
if (bUnassigned) return -1;
// Otherwise sort names normally
return currentDirection === 'asc'
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
}
// Numeric comparison
const numA = parseFloat(aValue);
const numB = parseFloat(bValue);
if (!isNaN(numA) && !isNaN(numB)) {
return currentDirection === 'asc' ? numA - numB : numB - numA;
}
// String comparison
return currentDirection === 'asc'
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
});
const currentHeader = headers[column];
currentHeader.classList.add(currentDirection === 'asc' ? 'sort-asc' : 'sort-desc');
const tbody = table.querySelector('tbody');
rows.forEach(row => tbody.appendChild(row));
}
// Old settings modal functions removed - now using settings.js with new settings modal
@@ -515,24 +458,59 @@ function bulkClose() {
return;
}
showConfirmModal(
`Close ${ticketIds.length} Ticket(s)?`,
'Are you sure you want to close these tickets?',
'warning',
() => performBulkCloseAction(ticketIds)
);
// Closing needs a reason: the default workflow marks every → Closed transition
// requires_comment, so collect it here instead of failing server-side.
const modalHtml = `
<div class="lt-modal-overlay" id="bulkCloseModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkCloseModalTitle">
<div class="lt-modal">
<div class="lt-modal-header" style="color:var(--terminal-amber)">
<span class="lt-modal-title" id="bulkCloseModalTitle">[ ! ] Close ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<label for="bulkCloseComment">Close Reason:</label>
<textarea id="bulkCloseComment" class="lt-input lt-w-full" rows="3"
placeholder="Why are these tickets being closed?…"
style="resize:vertical;font-family:inherit;font-size:0.8rem"
aria-label="Reason for closing the tickets"></textarea>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.35rem">
Posted as a comment on every ticket closed. Tickets whose workflow
forbids closing from their current status are skipped.
</p>
</div>
<div class="lt-modal-footer">
<button data-action="perform-bulk-close" class="lt-btn lt-btn-primary">CLOSE TICKETS</button>
<button data-action="close-bulk-close-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
openModalWithDismiss('bulkCloseModal', closeBulkCloseModal);
}
function closeBulkCloseModal() {
lt.modal.close('bulkCloseModal');
const modal = document.getElementById('bulkCloseModal');
if (modal) setTimeout(() => modal.remove(), 300);
}
function performBulkCloseAction(ticketIds) {
ticketIds = ticketIds || getSelectedTicketIds();
const commentEl = document.getElementById('bulkCloseComment');
const comment = commentEl ? commentEl.value.trim() : '';
lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_close',
ticket_ids: ticketIds
ticket_ids: ticketIds,
parameters: { comment: comment }
})
.then(data => {
closeBulkCloseModal();
if (data.success) {
if (data.failed > 0) {
lt.toast.warning(`Bulk close: ${data.processed} succeeded, ${data.failed} failed`, 5000);
lt.toast.warning(bulkResultMessage('Bulk close', data), 6000);
} else {
lt.toast.success(`Successfully closed ${data.processed} ticket(s)`, 4000);
}
@@ -542,6 +520,14 @@ function performBulkCloseAction(ticketIds) {
}
})
.catch(error => {
// Missing required comment — keep the modal open so it can be entered.
if (error && error.data && error.data.requires_comment) {
lt.toast.warning(error.data.error || 'A close reason is required', 6000);
const ta = document.getElementById('bulkCloseComment');
if (ta) ta.focus();
return;
}
closeBulkCloseModal();
lt.toast.error('Bulk close failed: ' + error.message, 5000);
});
}
@@ -584,7 +570,7 @@ function showBulkAssignModal() {
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('bulkAssignModal');
openModalWithDismiss('bulkAssignModal', closeBulkAssignModal);
setTimeout(() => { const inp = document.getElementById('bulkAssignUserInput'); if (inp) inp.focus(); }, 120);
lt.api.get('/api/get_users.php')
@@ -682,7 +668,7 @@ function showBulkPriorityModal() {
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('bulkPriorityModal');
openModalWithDismiss('bulkPriorityModal', closeBulkPriorityModal);
}
function closeBulkPriorityModal() {
@@ -777,6 +763,15 @@ function showBulkStatusModal() {
<option value="">Select Status...</option>
${(window.TICKET_STATUSES || ['Open','Pending','In Progress','Closed']).map(s => `<option value="${s}">${s}</option>`).join('')}
</select>
<label for="bulkStatusComment" style="margin-top:0.75rem">Reason / Comment:</label>
<textarea id="bulkStatusComment" class="lt-input lt-w-full" rows="3"
placeholder="Reason for the status change…"
style="resize:vertical;font-family:inherit;font-size:0.8rem"
aria-label="Reason for the bulk status change"></textarea>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.35rem">
Required for transitions the Workflow Designer marks as needing a comment
(e.g. closing a ticket). Posted as a comment on every ticket changed.
</p>
</div>
<div class="lt-modal-footer">
<button data-action="perform-bulk-status" class="lt-btn lt-btn-primary">UPDATE</button>
@@ -787,7 +782,7 @@ function showBulkStatusModal() {
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('bulkStatusModal');
openModalWithDismiss('bulkStatusModal', closeBulkStatusModal);
}
function closeBulkStatusModal() {
@@ -807,16 +802,19 @@ function performBulkStatusChange() {
return;
}
const commentEl = document.getElementById('bulkStatusComment');
const comment = commentEl ? commentEl.value.trim() : '';
lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_status',
ticket_ids: ticketIds,
parameters: { status: status }
parameters: { status: status, comment: comment }
})
.then(data => {
closeBulkStatusModal();
if (data.success) {
if (data.failed > 0) {
lt.toast.warning(`Status update: ${data.processed} succeeded, ${data.failed} failed`, 5000);
lt.toast.warning(bulkResultMessage('Status update', data), 6000);
} else {
lt.toast.success(`Successfully updated status for ${data.processed} ticket(s)`, 4000);
}
@@ -826,10 +824,32 @@ function performBulkStatusChange() {
}
})
.catch(error => {
// Workflow needs a comment for at least one selected ticket — keep the
// modal open so the reason can be typed in without re-selecting.
if (error && error.data && error.data.requires_comment) {
lt.toast.warning(error.data.error || 'A comment is required for this status change', 6000);
const ta = document.getElementById('bulkStatusComment');
if (ta) ta.focus();
return;
}
closeBulkStatusModal();
lt.toast.error('Bulk status change failed: ' + error.message, 5000);
});
}
/**
* Build a result message for a partially-successful bulk operation, surfacing the
* per-ticket reasons (e.g. "transition not allowed") instead of a bare count.
*/
function bulkResultMessage(label, data) {
let msg = `${label}: ${data.processed} succeeded, ${data.failed} failed`;
if (Array.isArray(data.errors) && data.errors.length) {
msg += ' — ' + data.errors.slice(0, 3).join('; ');
if (data.errors.length > 3) msg += ` (+${data.errors.length - 3} more)`;
}
return msg;
}
// Bulk Delete
function showBulkDeleteModal() {
const ticketIds = getSelectedTicketIds();
@@ -859,7 +879,7 @@ function showBulkDeleteModal() {
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('bulkDeleteModal');
openModalWithDismiss('bulkDeleteModal', closeBulkDeleteModal);
}
function closeBulkDeleteModal() {
@@ -949,6 +969,22 @@ function showInputModal(title, label, placeholder = '', onSubmit, onCancel = nul
input.addEventListener('keypress', (e) => { if (e.key === 'Enter') handleSubmit(); });
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
// Backdrop click / Escape close the overlay via base.js's global handlers.
modal.addEventListener('lt:modalclose', () => cleanup(onCancel));
}
/**
* Open a dynamically-inserted modal and make sure it tears itself down however it
* is dismissed. base.js handles backdrop clicks and Escape globally, so wiring
* only the ✕/Cancel buttons leaves the overlay in the DOM — and the next open
* inserts a second element with the same id, which then shadows the live one.
*/
function openModalWithDismiss(modalId, onDismiss) {
lt.modal.open(modalId);
const el = document.getElementById(modalId);
// lt.modal.close() early-returns once .is-open is gone, so the close call
// inside onDismiss cannot re-enter this listener.
if (el) el.addEventListener('lt:modalclose', onDismiss);
}
// ========================================
@@ -986,7 +1022,7 @@ function quickStatusChange(ticketId, currentStatus) {
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('quickStatusModal');
openModalWithDismiss('quickStatusModal', closeQuickStatusModal);
}
function closeQuickStatusModal() {
@@ -1000,18 +1036,20 @@ function performQuickStatusChange(ticketId) {
if (!quickStatusEl) return;
const newStatus = quickStatusEl.value;
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus })
// Close this modal first so the comment modal (if requires_comment) stacks cleanly.
closeQuickStatusModal();
lt.ticketStatus.submit(ticketId, newStatus)
.then(data => {
closeQuickStatusModal();
if (data.success) {
if (data && data.success) {
lt.toast.success(`Status updated to ${newStatus}`, 3000);
showTableSkeleton(5); setTimeout(() => window.location.reload(), 1000);
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
lt.toast.error('Error: ' + ((data && data.error) || 'Unknown error'), 4000);
}
})
.catch(error => {
closeQuickStatusModal();
if (error && error.cancelled) return;
lt.toast.error('Error updating status', 4000);
});
}
@@ -1034,12 +1072,11 @@ function quickAssign(ticketId) {
<div class="lt-modal-body">
<p class="lt-mb-xs lt-text-muted lt-text-xs">Ticket #${lt.escHtml(String(ticketId))}</p>
<label class="lt-label">Assign to:</label>
<div class="lt-combobox" id="quickAssignCombobox">
<div class="lt-combobox-input-wrap">
<input type="text" class="lt-combobox-input" id="quickAssignInput"
placeholder="Search users" autocomplete="off" aria-label="Search users">
</div>
<ul class="lt-combobox-list" role="listbox" aria-hidden="true"></ul>
<div class="lt-typeahead" id="quickAssignTypeahead" style="position:relative">
<input type="text" class="lt-input lt-w-full" id="quickAssignInput"
placeholder="Search users…" autocomplete="off" spellcheck="false"
aria-label="Search users" aria-autocomplete="list">
<div class="lt-typeahead-dropdown" id="quickAssignDropdown"></div>
</div>
</div>
<div class="lt-modal-footer">
@@ -1051,7 +1088,7 @@ function quickAssign(ticketId) {
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('quickAssignModal');
openModalWithDismiss('quickAssignModal', closeQuickAssignModal);
lt.api.get('/api/get_users.php')
.then(data => {
@@ -1065,7 +1102,9 @@ function quickAssign(ticketId) {
label: u.display_name || u.username
}))
];
lt.combobox.init(input, items, {
lt.typeahead.init(input, items, {
minChars: 1,
maxResults: 8,
onSelect: function(item) { _quickAssignUserId = item.value || null; }
});
}
@@ -1114,7 +1153,6 @@ function setViewMode(mode) {
if (mode === 'card') {
populateKanbanCards();
}
localStorage.setItem('ticketViewMode', mode);
}
/**
@@ -1142,8 +1180,10 @@ function populateKanbanCards() {
if (cells.length < 6) return;
const ticketId = cells[0 + offset]?.querySelector('.ticket-link')?.textContent.trim() || '';
const priorityEl = cells[1 + offset]?.querySelector('[class*="lt-p"]');
const priority = priorityEl ? priorityEl.textContent.trim().replace('P','') : cells[1 + offset]?.textContent.trim() || '4';
// The priority cell renders a "P1".."P5" badge; extract just the digit.
// (The old [class*="lt-p"] selector never matched the lt-badge-p1 class, so
// every card fell back to P4 regardless of real priority.)
const priority = (cells[1 + offset]?.textContent.trim() || '').replace(/[^0-9]/g, '') || '4';
const title = cells[2 + offset]?.textContent.trim() || '';
const category = cells[3 + offset]?.textContent.trim() || '';
const statusEl = cells[5 + offset]?.querySelector('.lt-status');
@@ -1166,8 +1206,9 @@ function populateKanbanCards() {
card.dataset.ticketId = ticketId;
card.dataset.status = status;
card.addEventListener('click', (e) => {
// Don't navigate if drag just ended (drag adds/removes is-dragging briefly)
if (card.dataset.dragged) { delete card.dataset.dragged; return; }
// Don't navigate if a drag just ended. The flag is cleared on a timer
// (see handleKanbanSort), so a genuine later click is not swallowed.
if (card.dataset.dragged) return;
window.location.href = '/ticket/' + encodeURIComponent(ticketId);
});
card.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') card.click(); };
@@ -1212,6 +1253,9 @@ function populateKanbanCards() {
movedCard.dataset.status = newStatus;
movedCard.dataset.dragged = '1';
// Clear the drag flag shortly after the drop so it suppresses only the
// synthetic click fired on drop, not the user's next genuine click.
setTimeout(function () { delete movedCard.dataset.dragged; }, 400);
// Optimistically update column counts
const dec = document.querySelector(`.column-count[data-status="${oldStatus}"]`);
@@ -1219,29 +1263,31 @@ function populateKanbanCards() {
if (dec) dec.textContent = '(' + Math.max(0, (parseInt(dec.textContent.replace(/\D/g,''),10)||1) - 1) + ')';
if (inc) inc.textContent = '(' + ((parseInt(inc.textContent.replace(/\D/g,''),10)||0) + 1) + ')';
// POST status update
fetch('/api/update_ticket.php', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN || '' },
body: JSON.stringify({ ticket_id: String(ticketId), status: newStatus })
})
.then(r => r.json())
.then(data => {
if (data.success) {
lt.toast.success('Ticket #' + ticketId + ' → ' + newStatus, 2500);
movedCard.dataset.status = newStatus;
} else {
lt.toast.error('Status update failed: ' + (data.error || 'Unknown error'));
// Revert: put card back in original column
const origCol = document.getElementById(Object.keys(colStatusMap).find(k => colStatusMap[k] === oldStatus));
if (origCol) origCol.appendChild(movedCard);
movedCard.dataset.status = oldStatus;
}
})
.catch(() => {
lt.toast.error('Network error — status not saved');
});
// Revert the card to its original column and undo the optimistic counts.
const revert = function () {
const origCol = document.getElementById(Object.keys(colStatusMap).find(k => colStatusMap[k] === oldStatus));
if (origCol) origCol.appendChild(movedCard);
movedCard.dataset.status = oldStatus;
if (dec) dec.textContent = '(' + ((parseInt(dec.textContent.replace(/\D/g, ''), 10) || 0) + 1) + ')';
if (inc) inc.textContent = '(' + Math.max(0, (parseInt(inc.textContent.replace(/\D/g, ''), 10) || 1) - 1) + ')';
};
// Submit via the shared comment-aware helper. Dropping to Closed (or
// reopening) prompts for a required comment and retries; cancel reverts.
lt.ticketStatus.submit(String(ticketId), newStatus)
.then(function (data) {
if (data && data.success) {
lt.toast.success('Ticket #' + ticketId + ' → ' + newStatus, 2500);
movedCard.dataset.status = newStatus;
} else {
lt.toast.error('Status update failed: ' + ((data && data.error) || 'Unknown error'));
revert();
}
})
.catch(function (error) {
if (!(error && error.cancelled)) lt.toast.error('Status update failed — reverting');
revert();
});
}
Object.keys(columns).forEach(status => {
@@ -1314,7 +1360,9 @@ function showTicketPreview(event) {
const offset = isAdmin ? 1 : 0;
const ticketId = link.textContent.trim();
const priority = cells[1 + offset]?.textContent.trim() || '';
// Cell text is already "P1".."P5"; strip the leading P so the template's
// `P${priority}` doesn't render "PP1".
const priority = (cells[1 + offset]?.textContent.trim() || '').replace(/^P/i, '');
const title = cells[2 + offset]?.textContent.trim() || '';
const category = cells[3 + offset]?.textContent.trim() || '';
const type = cells[4 + offset]?.textContent.trim() || '';
+19 -5
View File
@@ -6,11 +6,27 @@
// Track currently selected row for J/K navigation
let currentSelectedRowIndex = -1;
let lastNavRowCount = -1;
// Only navigate real, visible rows — skip skeleton placeholders and rows hidden
// by filters/column toggles (offsetParent is null when display:none).
function getNavigableRows() {
return Array.from(document.querySelectorAll('tbody tr')).filter(function(row) {
return !row.classList.contains('lt-skeleton-row') && row.offsetParent !== null;
});
}
function navigateTableRow(direction) {
const rows = document.querySelectorAll('tbody tr');
const rows = getNavigableRows();
if (rows.length === 0) return;
// Reset the index when the row set changes (e.g. filter/reload) so navigation
// never lands on a stale/hidden index.
if (rows.length !== lastNavRowCount) {
currentSelectedRowIndex = -1;
lastNavRowCount = rows.length;
}
rows.forEach(row => row.classList.remove('keyboard-selected'));
if (direction === 'next') {
@@ -47,10 +63,8 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
// ?: Show keyboard shortcuts help — use the static #lt-keys-help modal in the footer
lt.keys.on('?', function() {
if (window.lt) lt.modal.open('lt-keys-help');
});
// Note: the '?' help shortcut is registered by lt.keys.initDefaults(); do not
// re-bind it here or the help modal opens twice.
// J: Next row
lt.keys.on('j', () => navigateTableRow('next'));
+87 -32
View File
@@ -41,13 +41,22 @@ function parseMarkdown(markdown) {
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
// Ticket references (#123456789) - convert to clickable links
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
// Code blocks (```code```) - preserve content and don't process further
// Code blocks (```lang\ncode\n```) - preserve content and don't process further
const codeBlocks = [];
html = html.replace(/```([\s\S]*?)```/g, function(match, code) {
codeBlocks.push('<pre class="code-block"><code>' + code + '</code></pre>');
html = html.replace(/```([a-zA-Z0-9_+-]*)\n?([\s\S]*?)```/g, function(match, lang, code) {
lang = lang ? lang.trim() : '';
const displayLang = lang || 'text';
// Build header with optional copy button if one exists in your UI, otherwise just lang
const header = '<div class="lt-code-header"><span class="lt-code-lang">' + displayLang + '</span></div>';
// Remove exactly one trailing newline from code block if it exists
if (code.endsWith('\n')) {
code = code.slice(0, -1);
}
// Wrap in the specific UI classes expected by base.css
codeBlocks.push('<div class="lt-code-block">' + header + '<pre><code>' + code + '</code></pre></div>');
return '%%CODEBLOCK' + (codeBlocks.length - 1) + '%%';
});
@@ -58,6 +67,11 @@ function parseMarkdown(markdown) {
return '%%INLINECODE' + (inlineCodes.length - 1) + '%%';
});
// Ticket references (#123456789) - convert to clickable links.
// Runs AFTER code extraction so a literal #123456789 inside inline/fenced code
// (now replaced by a placeholder) is not turned into a link.
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
// Tables (must be processed before other block elements)
html = parseMarkdownTables(html);
@@ -142,12 +156,14 @@ function parseMarkdown(markdown) {
html = html.replace(/ \n/g, '<br>');
html = html.replace(/\n\n/g, '</p><p>');
// Restore code blocks and inline code
// Restore code blocks and inline code. Use a function replacer so '$'
// sequences in user code (e.g. $&, $$, $`, $') are inserted literally rather
// than interpreted as String.replace replacement patterns.
codeBlocks.forEach((block, i) => {
html = html.replace('%%CODEBLOCK' + i + '%%', block);
html = html.replace('%%CODEBLOCK' + i + '%%', () => block);
});
inlineCodes.forEach((code, i) => {
html = html.replace('%%INLINECODE' + i + '%%', code);
html = html.replace('%%INLINECODE' + i + '%%', () => code);
});
// Restore footnote reference placeholders
@@ -164,7 +180,7 @@ function parseMarkdown(markdown) {
// Append footnote definitions block
if (footnoteOrder.length) {
html += '<hr class="fn-hr"><ol class="fn-list">';
footnoteOrder.forEach(function(label, i) {
footnoteOrder.forEach(function(label) {
html += '<li id="fn-' + fnSlug(label) + '" class="fn-item">' +
parseMarkdown(footnotes[label]).replace(/<\/?p>/g, '') +
' <a href="#fnref-' + fnSlug(label) + '" class="fn-back">&#x21A9;</a></li>';
@@ -285,33 +301,45 @@ function buildTable(rows) {
if (rows.length === 0) return '';
let html = '<table class="markdown-table">';
let inThead = false;
let inTbody = false;
rows.forEach((row, index) => {
rows.forEach((row) => {
const cells = row.content.split('|').filter(cell => cell.trim() !== '');
const tag = row.type === 'header' ? 'th' : 'td';
const wrapper = row.type === 'header' ? 'thead' : (index === 1 ? 'tbody' : '');
const isHeader = row.type === 'header';
const tag = isHeader ? 'th' : 'td';
if (wrapper === 'thead') html += '<thead>';
if (wrapper === 'tbody') html += '<tbody>';
if (isHeader && !inThead) { html += '<thead>'; inThead = true; }
if (!isHeader && !inTbody) {
if (inThead) { html += '</thead>'; inThead = false; }
html += '<tbody>';
inTbody = true;
}
html += '<tr>';
cells.forEach(cell => {
html += `<${tag}>${cell.trim()}</${tag}>`;
});
html += '</tr>';
if (row.type === 'header') html += '</thead>';
});
html += '</tbody></table>';
// Close whichever section is still open so tags are balanced for header-only,
// body-only, and header+body tables alike.
if (inThead) html += '</thead>';
if (inTbody) html += '</tbody>';
html += '</table>';
return html;
}
// Apply markdown rendering to all elements with data-markdown attribute
function renderMarkdownElements() {
document.querySelectorAll('[data-markdown]').forEach(element => {
const markdownText = element.getAttribute('data-markdown') || element.textContent;
document.querySelectorAll('[data-markdown]:not([data-rendered])').forEach(element => {
// Trim so template indentation/whitespace in the element's text content
// doesn't get parsed as a leading code block (which breaks headings,
// tables, etc. and diverges from the live preview).
const markdownText = (element.getAttribute('data-markdown') || element.textContent).trim();
element.innerHTML = parseMarkdown(markdownText);
element.dataset.rendered = '1';
});
}
@@ -326,6 +354,33 @@ window.renderMarkdownElements = renderMarkdownElements;
// Rich Text Editor Toolbar Functions
// ========================================
/**
* Replace textarea.value.substring(selStart, selEnd) with replacementText,
* preserving the browser's native undo/redo stack via
* document.execCommand('insertText', ...) -- the same mechanism real typing
* uses -- instead of a direct .value assignment, which discards the entire
* undo history. Falls back to a direct assignment (losing undo, matching the
* old behavior) only if execCommand is unavailable or unsuccessful.
*/
function insertTextPreservingUndo(textarea, replacementText, selStart, selEnd) {
textarea.focus();
textarea.setSelectionRange(selStart, selEnd);
let inserted = false;
if (typeof document.execCommand === 'function') {
try {
inserted = document.execCommand('insertText', false, replacementText);
} catch (e) {
inserted = false;
}
}
if (!inserted) {
const text = textarea.value;
textarea.value = text.substring(0, selStart) + replacementText + text.substring(selEnd);
}
}
/**
* Insert markdown formatting around selection
*/
@@ -335,16 +390,13 @@ function insertMarkdownFormat(textareaId, prefix, suffix) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selectedText = text.substring(start, end);
const selectedText = textarea.value.substring(start, end);
// Insert formatting
const newText = text.substring(0, start) + prefix + selectedText + suffix + text.substring(end);
textarea.value = newText;
insertTextPreservingUndo(textarea, prefix + selectedText + suffix, start, end);
// Set cursor position
if (selectedText) {
textarea.setSelectionRange(start + prefix.length, end + prefix.length);
textarea.setSelectionRange(start + prefix.length, start + prefix.length + selectedText.length);
} else {
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
}
@@ -363,9 +415,10 @@ function insertMarkdownText(textareaId, text) {
if (!textarea) return;
const start = textarea.selectionStart;
const value = textarea.value;
textarea.value = value.substring(0, start) + text + value.substring(start);
// Matches the prior behavior: insert before the selection start without
// deleting any currently-selected text (a collapsed replace range).
insertTextPreservingUndo(textarea, text, start, start);
textarea.setSelectionRange(start + text.length, start + text.length);
textarea.focus();
@@ -425,7 +478,7 @@ function toolbarList(textareaId) {
}
// Insert list marker at beginning of line
textarea.value = text.substring(0, lineStart) + '- ' + text.substring(lineStart);
insertTextPreservingUndo(textarea, '- ', lineStart, lineStart);
textarea.setSelectionRange(start + 2, start + 2);
textarea.focus();
@@ -446,7 +499,7 @@ function toolbarHeading(textareaId) {
}
// Insert heading marker at beginning of line
textarea.value = text.substring(0, lineStart) + '## ' + text.substring(lineStart);
insertTextPreservingUndo(textarea, '## ', lineStart, lineStart);
textarea.setSelectionRange(start + 3, start + 3);
textarea.focus();
@@ -467,7 +520,7 @@ function toolbarQuote(textareaId) {
}
// Insert quote marker at beginning of line
textarea.value = text.substring(0, lineStart) + '> ' + text.substring(lineStart);
insertTextPreservingUndo(textarea, '> ', lineStart, lineStart);
textarea.setSelectionRange(start + 2, start + 2);
textarea.focus();
@@ -562,7 +615,9 @@ function processPlainTextComments() {
function renderMarkdownComments() {
document.querySelectorAll('.comment-text[data-markdown]:not([data-rendered])').forEach(el => {
el.classList.add('lt-markdown');
el.innerHTML = parseMarkdown(el.textContent);
// Trim template whitespace so the first line isn't parsed as an
// indented code block (matches the live-preview rendering).
el.innerHTML = parseMarkdown(el.textContent.trim());
el.dataset.rendered = '1';
});
}
+89 -32
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) {
var colors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
var h = 0;
for (var i = 0; i < displayName.length; i++) {
h = ((h << 5) - h + displayName.charCodeAt(i)) | 0;
}
return colors[Math.abs(h) % 4];
return colors[crc32(displayName) % 4];
}
/**
@@ -284,21 +304,23 @@ function addComment() {
// Clear the comment box
const nc = document.getElementById('newComment');
if (nc) nc.value = '';
// Clear the live preview — clearing the textarea programmatically
// does not fire 'input', so updatePreview() never runs
const previewDiv = document.getElementById('markdownPreview');
if (previewDiv) {
previewDiv.innerHTML = '';
previewDiv.classList.add('is-hidden');
}
// Format the comment text for display
let displayText;
if (isMarkdownEnabled) {
// For markdown, use parseMarkdown (sanitizes HTML)
displayText = parseMarkdown(commentText);
} else {
// For non-markdown, convert line breaks to <br> and escape HTML
displayText = commentText
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/\n/g, '<br>');
// For non-markdown, escape HTML then convert line breaks to <br>
displayText = lt.escHtml(commentText).replace(/\n/g, '<br>');
}
// Add new comment to the list
@@ -527,7 +549,19 @@ function updateTicketStatus() {
`);
const modal = document.getElementById(modalId);
lt.modal.open(modalId);
const cleanup = (ok) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (!ok) statusSelect.selectedIndex = 0; };
let settled = false;
const cleanup = (ok) => {
if (settled) return; // lt.modal.close() below re-enters via lt:modalclose
settled = true;
lt.modal.close(modalId);
setTimeout(() => modal.remove(), 300);
if (!ok) statusSelect.selectedIndex = 0;
};
// Backdrop click and Escape close the overlay through base.js's global
// handlers. Without this the dropdown kept displaying the new status
// while the server was never called, so the ticket looked closed until
// a reload revealed it was still open.
modal.addEventListener('lt:modalclose', () => cleanup(false));
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(false));
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(false));
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => {
@@ -538,11 +572,12 @@ function updateTicketStatus() {
return;
}
cleanup(true);
// Post comment first, then change status
// Post comment first (persists it), then change status with the same
// comment included so the server's requires_comment check passes.
const ticketId = getTicketIdFromUrl();
lt.api.post('/api/add_comment.php', { ticket_id: ticketId, comment_text: comment })
.then(() => performStatusChange(statusSelect, selectedOption, newStatus))
.catch(() => performStatusChange(statusSelect, selectedOption, newStatus));
.then(() => performStatusChange(statusSelect, selectedOption, newStatus, comment))
.catch(() => performStatusChange(statusSelect, selectedOption, newStatus, comment));
});
// Focus textarea on open
setTimeout(() => { const ta = document.getElementById(`${modalId}_comment`); if (ta) ta.focus(); }, 100);
@@ -552,8 +587,11 @@ function updateTicketStatus() {
performStatusChange(statusSelect, selectedOption, newStatus);
}
// Extract status change logic into reusable function
function performStatusChange(statusSelect, selectedOption, newStatus) {
// Extract status change logic into reusable function.
// `comment` (optional) is included in the update_ticket payload so requires_comment
// transitions pass server validation. lt.ticketStatus.submit handles the
// comment-aware retry if a comment is required but was not pre-collected.
function performStatusChange(statusSelect, selectedOption, newStatus, comment) {
const ticketId = getTicketIdFromUrl();
if (!ticketId) {
@@ -561,10 +599,10 @@ function performStatusChange(statusSelect, selectedOption, newStatus) {
return;
}
// Update status via API
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus })
// Update status via the shared comment-aware helper
lt.ticketStatus.submit(ticketId, newStatus, { comment: comment })
.then(data => {
if (data.success) {
if (data && data.success) {
// Update the dropdown to show new status as current (preserve TDS v1.2 classes)
const newClass = 'lt-status-' + newStatus.toLowerCase().replace(/ /g, '-');
statusSelect.className = 'lt-select lt-select-sm lt-status-select ' + newClass;
@@ -582,12 +620,14 @@ function performStatusChange(statusSelect, selectedOption, newStatus) {
window.location.reload();
}, 500);
} else {
lt.toast.error('Error updating status: ' + (data.error || 'Unknown error'));
lt.toast.error('Error updating status: ' + ((data && data.error) || 'Unknown error'));
// Reset to current status
statusSelect.selectedIndex = 0;
}
})
.catch(error => {
// User cancelled the required-comment modal — silently revert the dropdown
if (error && error.cancelled) { statusSelect.selectedIndex = 0; return; }
lt.toast.error('Error updating status: ' + error.message);
// Reset to current status
statusSelect.selectedIndex = 0;
@@ -938,6 +978,8 @@ function handleFileUpload(files) {
if (xhr.status === 200 || xhr.status === 201) {
try {
const response = JSON.parse(xhr.responseText);
// Keep the CSRF token in sync if the server rotated it
if (response.csrf_token) window.CSRF_TOKEN = response.csrf_token;
if (response.success) {
if (uploadedCount === totalFiles) {
lt.toast.success(`${totalFiles} file(s) uploaded successfully`, 3000);
@@ -968,6 +1010,9 @@ function handleFileUpload(files) {
});
xhr.open('POST', '/api/upload_attachment.php');
// Send CSRF via header to match the rest of the app (endpoint accepts both
// the X-CSRF-Token header and the csrf_token form field).
if (window.CSRF_TOKEN) xhr.setRequestHeader('X-CSRF-Token', window.CSRF_TOKEN);
xhr.send(formData);
});
}
@@ -1023,7 +1068,7 @@ function renderAttachments(attachments) {
});
const uploadDate = `<span class="ts-cell" data-ts="${lt.escHtml(att.uploaded_at)}" title="${lt.escHtml(uploadDateFormatted)}">${lt.time.ago(att.uploaded_at)}</span>`;
const isImage = /\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(att.original_filename);
const isImage = /^image\//i.test(att.mime_type || '');
const imgUrl = `/api/download_attachment.php?id=${att.attachment_id}&inline=1`;
const iconHtml = isImage
? `<a href="${imgUrl}" class="lt-lightbox-trigger" data-lightbox="ticket-attachments" title="${lt.escHtml(att.original_filename)}">
@@ -1142,12 +1187,17 @@ function handleMentionInput(e) {
const text = textarea.value;
const cursorPos = textarea.selectionStart;
// Find @ symbol before cursor
// Find @ symbol before cursor. Only trigger when the @ is at a word boundary
// (start of input or preceded by whitespace) so it does not fire inside email
// addresses like foo@bar.
let atPos = -1;
for (let i = cursorPos - 1; i >= 0; i--) {
const char = text[i];
if (char === '@') {
atPos = i;
const prev = i > 0 ? text[i - 1] : '';
if (i === 0 || /\s/.test(prev)) {
atPos = i;
}
break;
}
if (char === ' ' || char === '\n') {
@@ -1277,20 +1327,27 @@ function selectMention(username) {
}
/**
* Highlight mentions in comment text
* Highlight mentions in comment text.
* Skips content inside existing anchor tags so URLs/emails that contain '@'
* (e.g. auto-linked links or mailto:) are not corrupted or nested.
*/
function highlightMentions(text) {
return text.replace(/@([a-zA-Z0-9_-]+)/g, '<span class="mention">$1</span>');
return text.replace(/<a\b[^>]*>[\s\S]*?<\/a>|@[a-zA-Z0-9_-]+/gi, function (m) {
if (m.charAt(0) === '<') return m; // leave anchor tags untouched
return '<span class="mention">' + m.slice(1) + '</span>';
});
}
// Initialize mention autocomplete when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
initMentionAutocomplete();
// Highlight @mentions in plain-text comments (markdown.js handles [data-markdown] elements)
// Highlight @mentions in plain-text comments (markdown.js handles [data-markdown] elements).
// Idempotency guard: only process each element once so re-runs don't nest spans.
document.querySelectorAll('.comment-text').forEach(el => {
if (!el.hasAttribute('data-markdown')) {
if (!el.hasAttribute('data-markdown') && !el.dataset.mentionsProcessed) {
el.innerHTML = highlightMentions(el.innerHTML);
el.dataset.mentionsProcessed = '1';
}
});
+5
View File
@@ -6,6 +6,9 @@ if (!file_exists($envFile)) {
die('Configuration error: .env file not found. Copy .env.example to .env and configure your database settings.');
}
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
if (!is_array($envVars)) {
die('Configuration error: .env file could not be parsed. Check for unquoted special characters (e.g. #, ;, =, or quotes) in values and wrap affected values in double quotes.');
}
// Strip quotes from values if present (parse_ini_file may include them)
if ($envVars) {
@@ -112,6 +115,8 @@ $GLOBALS['config'] = [
// File upload settings
'MAX_UPLOAD_SIZE' => 10485760, // 10MB in bytes
'MAX_ATTACHMENTS_PER_TICKET' => 50,
'MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET' => 104857600, // 100MB in bytes
'ALLOWED_FILE_TYPES' => [
'image/jpeg',
'image/png',
+7
View File
@@ -25,4 +25,11 @@ return [
'fileinfo', // api/upload_attachment.php — MIME validation
'json', // request/response encoding (bundled, but assert anyway)
],
// Sanity-check thresholds (warnings, not hard failures). A host with a low
// default memory_limit passes a bare extension/version check cleanly and
// only surfaces as a mysterious failure under real load — a large CSV
// export, an oversized dashboard query on a big install.
'min_memory_limit_mb' => 256,
'min_max_execution_time' => 30, // seconds; 0 (unlimited) always passes
];
+24 -3
View File
@@ -93,19 +93,27 @@ class TicketController
$visibilityGroups = implode(',', array_map('trim', $_POST['visibility_groups']));
}
// Honor the posted status, validated against the app's canonical list
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
$status = $_POST['status'] ?? 'Open';
if (!in_array($status, $validStatuses, true)) {
$status = 'Open';
}
$ticketData = [
'title' => $_POST['title'] ?? '',
'title' => trim($_POST['title'] ?? ''),
'description' => $_POST['description'] ?? '',
'priority' => $_POST['priority'] ?? '4',
'category' => $_POST['category'] ?? 'General',
'type' => $_POST['type'] ?? 'Issue',
'status' => $status,
'visibility' => $_POST['visibility'] ?? 'public',
'visibility_groups' => $visibilityGroups,
'assigned_to' => !empty($_POST['assigned_to']) ? $_POST['assigned_to'] : null
];
// Validate input
if (empty($ticketData['title'])) {
// Validate input (server-side; form is novalidate)
if ($ticketData['title'] === '') {
$error = "Title is required";
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
@@ -114,6 +122,15 @@ class TicketController
return;
}
if (trim($ticketData['description']) === '') {
$error = "Description is required";
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn; // Make $conn available to view
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
// Create ticket with user tracking
$result = $this->ticketModel->createTicket($ticketData, $userId);
@@ -123,6 +140,10 @@ class TicketController
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
}
// Ticket counts changed — invalidate the cached dashboard stats
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($this->conn))->invalidateCache();
// Auto-link as duplicate if requested from create form
$linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
+73 -26
View File
@@ -60,6 +60,7 @@ require_once __DIR__ . '/config/config.php';
// Authenticate via API key
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
require_once __DIR__ . '/models/AuditLogModel.php';
require_once __DIR__ . '/models/StatsModel.php';
require_once __DIR__ . '/helpers/UrlHelper.php';
$apiKeyAuth = new ApiKeyAuth($conn);
@@ -71,20 +72,11 @@ try {
exit;
}
// Ticket creation is a write — a read-only key must be rejected with 403.
$apiKeyAuth->requireScope('read_write');
$userId = $systemUser['user_id'];
// Create tickets table with hash column if not exists
$createTableSQL = "CREATE TABLE IF NOT EXISTS tickets (
id INT AUTO_INCREMENT PRIMARY KEY,
ticket_id VARCHAR(9) NOT NULL,
title VARCHAR(255) NOT NULL,
hash VARCHAR(64) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_hash (hash)
)";
$conn->query($createTableSQL);
// Parse input regardless of content-type header
$rawInput = file_get_contents('php://input');
$data = json_decode($rawInput, true);
@@ -195,10 +187,17 @@ function generateTicketHash($data)
'source_type' => $sourceType,
'issue_category' => $issueCategory,
'issue_subtype' => $issueSubtype,
'environment_tags' => array_values(array_filter(
explode('][', $title),
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'])
)),
'environment_tags' => (function () use ($title) {
// Extract each [bracketed] tag, then keep the known environment ones.
// (explode('][') leaves brackets stuck to the first/last tag, so e.g.
// "[production] ..." never matched and the env tag was dropped from the
// dedup hash — letting prod and staging issues collide onto one ticket.)
preg_match_all('/\[([^\]]+)\]/', $title, $m);
return array_values(array_filter(
$m[1],
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'], true)
));
})(),
];
// Manual tickets should be unique by title (so different software installs don't collide)
@@ -305,9 +304,18 @@ if ($existing) {
$updStmt->close();
// Only post a comment on priority escalation — title and description updates
// are silent (title changes like rising counters would spam a comment every run)
// are silent (title changes like rising counters would spam a comment every run).
// Keep it short: the full sensor data is refreshed in the ticket description,
// so the comment just records the bump + a brief reason (no ASCII dump).
if (isset($changes['priority'])) {
$commentText = "**hwmonDaemon escalated this ticket from P{$changes['priority']['from']} to P{$changes['priority']['to']}.**\n\n```\n" . $description . "\n```";
$pLabels = [1 => 'P1 (Critical)', 2 => 'P2 (High)', 3 => 'P3 (Medium)', 4 => 'P4 (Low)', 5 => 'P5 (Minimal)'];
$fromP = (int)$changes['priority']['from'];
$toP = (int)$changes['priority']['to'];
$fromL = $pLabels[$fromP] ?? "P{$fromP}";
$toL = $pLabels[$toP] ?? "P{$toP}";
$commentText = "**hwmonDaemon raised priority {$fromL}{$toL}.**\n\n"
. "The latest monitoring scan reported a more severe condition for this issue, "
. "so it now needs faster attention. Current sensor data is in the ticket description above.";
$commentStmt = $conn->prepare(
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
);
@@ -333,6 +341,9 @@ if ($existing) {
'status' => $existingStatus,
], 'automated');
}
// Ticket state (priority/title/description) changed — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
}
$conn->close();
@@ -355,7 +366,8 @@ if ($existing) {
$reopenStmt->close();
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
"New report received from hwmonDaemon:\n\n```\n" . $description . "\n```";
"hwmonDaemon detected this condition again. The ticket description reflects the "
. "original report; see this comment's timestamp for when the issue recurred.";
$commentStmt = $conn->prepare(
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
);
@@ -368,6 +380,9 @@ if ($existing) {
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
]);
// Ticket reopened (Closed → Open) — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
$conn->close();
require_once __DIR__ . '/helpers/NotificationHelper.php';
@@ -388,13 +403,40 @@ if ($existing) {
exit;
}
// No existing ticket — create a new one
// Use random_int range 100000000-999999999 to avoid leading-zero IDs
try {
$ticket_id = (string)random_int(100000000, 999999999);
} catch (Exception $e) {
$ticket_id = (string)mt_rand(100000000, 999999999);
// No existing ticket — create a new one.
// Generate a collision-safe unique ticket_id with a pre-check + retry loop (same
// approach as TicketModel::createTicket) so a ticket_id clash cannot happen. That
// way a 1062 on INSERT below can only be the unique_hash (dedup) key racing, and
// is correctly reported as a duplicate rather than a dropped hardware alert.
$ticket_id = null;
$maxAttempts = 50;
$attempts = 0;
do {
try {
$candidateId = sprintf('%09d', random_int(100000000, 999999999));
} catch (Exception $e) {
$candidateId = sprintf('%09d', mt_rand(100000000, 999999999));
}
$idCheckStmt = $conn->prepare("SELECT ticket_id FROM tickets WHERE ticket_id = ? LIMIT 1");
$idCheckStmt->bind_param("s", $candidateId);
$idCheckStmt->execute();
$idExists = $idCheckStmt->get_result()->num_rows > 0;
$idCheckStmt->close();
if (!$idExists) {
$ticket_id = $candidateId;
}
$attempts++;
} while ($ticket_id === null && $attempts < $maxAttempts);
if ($ticket_id === null) {
error_log('create_ticket_api: failed to generate a unique ticket_id after ' . $maxAttempts . ' attempts');
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Internal server error']);
exit;
}
$insertStmt = $conn->prepare(
"INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
@@ -436,6 +478,9 @@ if ($inserted) {
'type' => $type,
]);
// New ticket created — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
$conn->close();
require_once __DIR__ . '/helpers/NotificationHelper.php';
@@ -453,5 +498,7 @@ if ($inserted) {
'message' => 'Ticket created successfully',
]);
} else {
echo json_encode(['success' => false, 'error' => $conn->error]);
error_log('create_ticket_api: ticket insert reported failure: ' . $conn->error);
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Internal server error']);
}
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env php
<?php
/**
* Audit Log Retention Cron Job
*
* Deletes audit_log rows older than AUDIT_LOG_RETENTION_DAYS (config, default 90).
* Recommended: run once daily.
*
* Example crontab entry (03:30 every day):
* 30 3 * * * /usr/bin/php /path/to/cron/cleanup_audit_log.php >> /var/log/audit_log_cleanup.log 2>&1
*/
// Prevent web access
if (php_sapi_name() !== 'cli') {
http_response_code(403);
exit('CLI access only');
}
// Change to project root directory
chdir(dirname(__DIR__));
// Include required files
require_once 'config/config.php';
require_once 'helpers/Database.php';
require_once 'models/AuditLogModel.php';
// Log function
function logMessage($message)
{
echo '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n";
}
$retentionDays = (int)($GLOBALS['config']['AUDIT_LOG_RETENTION_DAYS'] ?? 90);
logMessage("Starting audit log cleanup (retention: {$retentionDays} days)");
try {
$conn = Database::getConnection();
$auditLog = new AuditLogModel($conn);
$deleted = $auditLog->deleteOldLogs($retentionDays);
logMessage("Removed {$deleted} audit log row(s) older than {$retentionDays} days");
Database::close();
} catch (Exception $e) {
logMessage('FATAL ERROR: ' . $e->getMessage());
exit(1);
}
+12
View File
@@ -17,9 +17,11 @@ chdir(dirname(__DIR__));
// Include required files
require_once 'config/config.php';
require_once 'helpers/Database.php';
require_once 'helpers/NotificationHelper.php';
require_once 'models/RecurringTicketModel.php';
require_once 'models/TicketModel.php';
require_once 'models/AuditLogModel.php';
require_once 'models/StatsModel.php';
// Log function
function logMessage($message)
@@ -92,6 +94,10 @@ try {
['source' => 'recurring', 'recurring_id' => $recurring['recurring_id']]
);
// Fire the same Matrix "ticket created" notification the manual and
// external-API create paths send, so recurring tickets aren't silent.
NotificationHelper::sendTicketNotification($ticketId, $ticketData, 'automated');
$created++;
} else {
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
@@ -103,6 +109,12 @@ try {
}
}
// Ticket counts changed — invalidate the cached dashboard stats once for the
// whole run (mirrors the manual/API create paths, which invalidate per create).
if ($created > 0) {
(new StatsModel($conn))->invalidateCache();
}
logMessage("Completed: Created $created tickets, $errors errors");
Database::close();
-107
View File
@@ -1,107 +0,0 @@
<?php
/**
* API Key Generator for hwmonDaemon
* Run this script once after migrations to generate the API key
*
* Usage: php generate_api_key.php
*/
// Prevent web access
if (php_sapi_name() !== 'cli') {
http_response_code(403);
exit('CLI access only');
}
require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/models/ApiKeyModel.php';
require_once __DIR__ . '/models/UserModel.php';
echo "==============================================\n";
echo " Tinker Tickets - API Key Generator\n";
echo "==============================================\n\n";
// Create database connection
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
die("❌ Database connection failed: " . $conn->connect_error . "\n");
}
echo "✅ Connected to database\n\n";
// Initialize models
$userModel = new UserModel($conn);
$apiKeyModel = new ApiKeyModel($conn);
// Get system user (should exist from migration)
echo "Checking for system user...\n";
$systemUser = $userModel->getSystemUser();
if (!$systemUser) {
die("❌ Error: System user not found. Please run migrations first.\n");
}
echo "✅ System user found: ID " . $systemUser['user_id'] . " (" . $systemUser['username'] . ")\n\n";
// Check if API key already exists
$existingKeys = $apiKeyModel->getKeysByUser($systemUser['user_id']);
if (!empty($existingKeys)) {
echo "⚠️ Warning: API keys already exist for system user:\n\n";
foreach ($existingKeys as $key) {
echo " - " . $key['key_name'] . " (Prefix: " . $key['key_prefix'] . ")\n";
echo " Created: " . $key['created_at'] . "\n";
echo " Active: " . ($key['is_active'] ? 'Yes' : 'No') . "\n\n";
}
echo "Do you want to generate a new API key? (yes/no): ";
$handle = fopen("php://stdin", "r");
$response = trim(fgets($handle));
fclose($handle);
if (strtolower($response) !== 'yes') {
echo "\nAborted.\n";
exit(0);
}
echo "\n";
}
// Generate API key
echo "Generating API key for hwmonDaemon...\n";
$result = $apiKeyModel->createKey(
'hwmonDaemon',
$systemUser['user_id'],
null // No expiration
);
if ($result['success']) {
echo "\n";
echo "==============================================\n";
echo " ✅ API Key Generated Successfully!\n";
echo "==============================================\n\n";
echo "API Key: " . $result['api_key'] . "\n";
echo "Key Prefix: " . $result['key_prefix'] . "\n";
echo "Key ID: " . $result['key_id'] . "\n";
echo "Expires: Never\n\n";
echo "⚠️ IMPORTANT: Save this API key now!\n";
echo " It cannot be retrieved later.\n\n";
echo "==============================================\n";
echo " Add to hwmonDaemon .env file:\n";
echo "==============================================\n\n";
echo "TICKET_API_KEY=" . $result['api_key'] . "\n\n";
echo "Then restart hwmonDaemon:\n";
echo " sudo systemctl restart hwmonDaemon\n\n";
} else {
echo "❌ Error generating API key: " . $result['error'] . "\n";
exit(1);
}
$conn->close();
echo "Done! Delete this script after use:\n";
echo " rm " . __FILE__ . "\n\n";
+63 -9
View File
@@ -21,7 +21,13 @@ class CacheHelper
if (self::$cacheDir === null) {
self::$cacheDir = sys_get_temp_dir() . '/tinker_tickets_cache';
if (!is_dir(self::$cacheDir)) {
mkdir(self::$cacheDir, 0755, true);
// 0700: only the app user may read cached data or create files.
// mkdir mode is masked by umask, so chmod to enforce it.
mkdir(self::$cacheDir, 0700, true);
@chmod(self::$cacheDir, 0700);
} elseif (!function_exists('posix_geteuid') || fileowner(self::$cacheDir) === posix_geteuid()) {
// Existing dir we own: harden a previously world-readable dir.
@chmod(self::$cacheDir, 0700);
}
}
return self::$cacheDir;
@@ -106,7 +112,40 @@ class CacheHelper
// Store in file cache
$filePath = self::getCacheDir() . '/' . $key . '.json';
return @file_put_contents($filePath, json_encode($cached), LOCK_EX) !== false;
$written = @file_put_contents($filePath, json_encode($cached), LOCK_EX) !== false;
if ($written) {
// 0600: cache may feed security-relevant reads; keep it non-readable
// to other local users and non-poisonable by pre-created files.
@chmod($filePath, 0600);
}
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);
}
}
/**
@@ -118,6 +157,8 @@ class CacheHelper
*/
public static function delete(string $prefix, $identifier = null): bool
{
self::bumpEpoch($prefix);
if ($identifier !== null) {
$key = self::makeKey($prefix, $identifier);
unset(self::$memoryCache[$key]);
@@ -125,16 +166,23 @@ class CacheHelper
return !file_exists($filePath) || @unlink($filePath);
}
// Delete all files with this prefix
$pattern = self::getCacheDir() . '/' . preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix) . '*.json';
$files = glob($pattern);
// Delete all entries for this prefix. A key is either the bare prefix or
// prefix + '_' + md5(identifier) (32 hex chars, see makeKey). Match exactly
// that so a prefix can't clobber a different prefix that merely shares a
// leading substring — e.g. delete('workflow') must not wipe 'workflow_rules'.
$safePrefix = preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix);
$keyRegex = '/^' . preg_quote($safePrefix, '/') . '(_[0-9a-f]{32})?$/';
$files = glob(self::getCacheDir() . '/' . $safePrefix . '*.json') ?: [];
foreach ($files as $file) {
@unlink($file);
if (preg_match($keyRegex, basename($file, '.json'))) {
@unlink($file);
}
}
// Clear memory cache entries with this prefix
// Clear matching memory cache entries
foreach (array_keys(self::$memoryCache) as $key) {
if (strpos($key, $prefix) === 0) {
if (preg_match($keyRegex, $key)) {
unset(self::$memoryCache[$key]);
}
}
@@ -173,8 +221,14 @@ class CacheHelper
$data = self::get($prefix, $identifier, $ttl);
if ($data === null) {
// Snapshot the epoch before running the (possibly slow) callback so
// a concurrent invalidation mid-computation can be detected below —
// otherwise this request's stale pre-invalidation result could
// overwrite a newer request's fresher write, extending staleness by
// up to another full TTL.
$epochBefore = self::getEpoch($prefix);
$data = $callback();
if ($data !== null) {
if ($data !== null && self::getEpoch($prefix) === $epochBefore) {
self::set($prefix, $identifier, $data);
}
}
+29 -5
View File
@@ -22,11 +22,9 @@ class Database
self::$connection = self::createConnection();
}
// Check if connection is still alive
if (!self::$connection->ping()) {
self::$connection = self::createConnection();
}
// Note: no ping()/reconnect check — mysqli auto-reconnect was removed in
// PHP 8.2 and mysqli::ping() is deprecated in 8.4. The connection is
// request-scoped and short-lived, so a liveness check is unnecessary.
return self::$connection;
}
@@ -57,6 +55,32 @@ class Database
// Set charset to utf8mb4 for proper Unicode support
$conn->set_charset('utf8mb4');
// Pin the MySQL session time zone to the app's configured zone so that
// NOW()/CURRENT_TIMESTAMP and PHP agree on wall-clock time regardless of
// the DB server's SYSTEM tz. Prefer the named zone (requires the
// mysql.time_zone_* tables); if that isn't available, fall back to the
// fixed numeric offset PHP computes for the same zone. Best-effort: a
// failure here must never fatal the connection.
$tz = $GLOBALS['config']['TIMEZONE'] ?? 'UTC';
try {
$escaped = $conn->real_escape_string($tz);
try {
// mysqli throws (does not return false) on failure under the
// default PHP 8.1+ report mode, so catch it rather than testing
// the return value.
$conn->query("SET time_zone = '{$escaped}'");
} catch (\Throwable $inner) {
// Named zone unavailable (mysql.time_zone_* not populated) — fall
// back to a fixed numeric offset so PHP and MySQL still agree on
// wall-clock time regardless of the DB server's SYSTEM tz.
$offset = (new DateTime('now', new DateTimeZone($tz)))->format('P');
$escapedOffset = $conn->real_escape_string($offset);
$conn->query("SET time_zone = '{$escapedOffset}'");
}
} catch (\Throwable $e) {
error_log('Database: failed to set session time_zone: ' . $e->getMessage());
}
return $conn;
}
+26 -4
View File
@@ -96,21 +96,31 @@ class NotificationHelper
* @param string $commentText Plain text (first 200 chars will be sent)
* @param string|null $authorDisplay Display name of commenter
* @param bool $isInternal True if the comment is internal-only
* @param string $visibility Ticket visibility: 'public', 'internal', or
* 'confidential'. For non-public tickets the
* comment text preview is redacted so it is
* never leaked to the shared notify list.
*/
public static function sendCommentNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay = null, bool $isInternal = false): void
public static function sendCommentNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay = null, bool $isInternal = false, string $visibility = 'public'): void
{
// Skip if this is an internal-only comment — only the assignee/admin need to know
$notifyUsers = self::notifyUsers();
if (empty($notifyUsers)) {
return;
}
// The shared notify list may include users without access to non-public
// tickets, so never post the comment body for internal/confidential
// tickets — only that activity occurred.
$preview = $visibility === 'public'
? mb_strimwidth($commentText, 0, 200, '…')
: null;
self::fire([
'event' => 'comment_added',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'author' => $authorDisplay,
'preview' => mb_strimwidth($commentText, 0, 200, '…'),
'preview' => $preview,
'is_internal' => $isInternal,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => $notifyUsers,
@@ -155,8 +165,14 @@ class NotificationHelper
* @param string $event One of: status_changed, comment_added, assigned
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
* @param int|null $excludeUserId Don't notify the actor themselves
* @param string $visibility Ticket visibility: 'public', 'internal', or
* 'confidential'. notify_users includes the
* shared list, which may contain users without
* access to non-public tickets, so any comment
* body preview in $extraData is redacted for
* non-public tickets.
*/
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null): void
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null, string $visibility = 'public'): void
{
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
@@ -164,6 +180,12 @@ class NotificationHelper
return;
}
// Don't leak comment/body content to the shared notify list for
// non-public tickets — keep only the fact that activity occurred.
if ($visibility !== 'public' && isset($extraData['preview'])) {
$extraData['preview'] = null;
}
// Fetch watcher usernames, excluding the actor so they don't notify
// themselves. Notifications are best-effort: if the watchers table is
// absent or the query fails, skip silently rather than fataling the
-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);
}
+33 -12
View File
@@ -4,8 +4,9 @@
* SynapseHelper
*
* Resolves local (SSO) usernames Matrix user IDs by querying the
* Synapse Admin REST API directly. No caching every call is live
* so results never go stale.
* Synapse Admin REST API directly. Results are memoized per-request (not
* across requests, so they don't go stale between requests), and a batch
* resolve has an overall time budget to bound request latency.
*
* Required config (.env) keys:
* MATRIX_DOMAIN e.g. matrix.lotusguild.org
@@ -14,6 +15,12 @@
*/
class SynapseHelper
{
/** Per-request memo of username => Matrix ID|null, so repeat watchers are free. */
private static array $cache = [];
/** Total wall-clock budget (seconds) for a single resolveUsernames() batch. */
private const RESOLVE_BUDGET_SECONDS = 5;
/**
* Resolve a local SSO username to its Matrix user ID.
*
@@ -29,6 +36,11 @@ class SynapseHelper
*/
public static function resolveUsername(string $username): ?string
{
// Serve from the per-request cache when we've already looked this up.
if (array_key_exists($username, self::$cache)) {
return self::$cache[$username];
}
$baseUrl = $GLOBALS['config']['SYNAPSE_ADMIN_URL'] ?? null;
$token = $GLOBALS['config']['SYNAPSE_ADMIN_TOKEN'] ?? null;
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
@@ -49,6 +61,7 @@ class SynapseHelper
'Accept: application/json',
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 2); // fail fast when Synapse is unreachable
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
$body = curl_exec($ch);
@@ -56,25 +69,24 @@ class SynapseHelper
$curlError = curl_error($ch);
curl_close($ch);
$resolved = null;
if ($curlError) {
error_log("SynapseHelper: cURL error resolving '{$username}': {$curlError}");
return null;
}
if ($httpCode === 200) {
} elseif ($httpCode === 200) {
$data = json_decode($body, true);
// Confirm the response contains the name we expect
if (!empty($data['name'])) {
return $data['name']; // e.g. "@jared:matrix.lotusguild.org"
$resolved = $data['name']; // e.g. "@jared:matrix.lotusguild.org"
}
}
// 404 = user not found in Synapse; other codes = error
if ($httpCode !== 404) {
} elseif ($httpCode !== 404) {
// 404 = user not found in Synapse; other codes = error
error_log("SynapseHelper: unexpected HTTP {$httpCode} resolving '{$username}'");
}
return null;
// Memoize for the rest of this request (including negative results, so a
// missing/unreachable user isn't retried within the same request).
self::$cache[$username] = $resolved;
return $resolved;
}
/**
@@ -87,7 +99,16 @@ class SynapseHelper
public static function resolveUsernames(array $usernames): array
{
$ids = [];
$deadline = microtime(true) + self::RESOLVE_BUDGET_SECONDS;
foreach ($usernames as $username) {
// Cached lookups are free and always allowed; for uncached ones, stop
// making live calls once the batch budget is spent so a slow/unreachable
// Synapse can't stall the request for N × per-call timeout.
$cached = array_key_exists($username, self::$cache);
if (!$cached && microtime(true) >= $deadline) {
error_log('SynapseHelper: resolve budget exhausted; skipping remaining lookups');
break;
}
$id = self::resolveUsername($username);
if ($id !== null) {
$ids[] = $id;
+24 -7
View File
@@ -249,8 +249,11 @@ switch (true) {
$params = [];
$types = '';
$allowedActionTypes = ['create','update','delete','comment','assign','status_change','login','security',
'ticket_create','ticket_update','ticket_delete','attachment_delete','attachment_upload'];
// Mirrors AuditLogModel::VALID_ACTION_TYPES so every option offered by the
// audit-log filter dropdown is actually accepted here.
$allowedActionTypes = ['create','update','delete','view','security_event',
'login','logout','assign','unassign','comment','mention',
'revoke','attachment_upload','attachment_delete','bulk_update'];
if (!empty($_GET['action_type']) && in_array($_GET['action_type'], $allowedActionTypes, true)) {
$whereConditions[] = "al.action_type = ?";
$params[] = $_GET['action_type'];
@@ -328,16 +331,27 @@ switch (true) {
requireAdmin($currentUser);
require_once 'models/ApiKeyModel.php';
$apiKeyModel = new ApiKeyModel($conn);
$apiKeys = $apiKeyModel->getAllKeys();
// Validate the requested page to a positive int (default 1)
$apiKeysPage = isset($_GET['page']) ? (int)$_GET['page'] : 1;
if ($apiKeysPage < 1) {
$apiKeysPage = 1;
}
$apiKeysPerPage = 20;
$apiKeys = $apiKeyModel->getAllKeys($apiKeysPage, $apiKeysPerPage);
include 'views/admin/ApiKeysView.php';
break;
case $requestPath == '/admin/user-activity':
requireAdmin($currentUser);
// Validate date params (YYYY-MM-DD) like the audit-log route; fall back to defaults on garbage
$uaFrom = $_GET['date_from'] ?? '';
$uaTo = $_GET['date_to'] ?? '';
$dateRange = [
'from' => $_GET['date_from'] ?? date('Y-m-d', strtotime('-30 days')),
'to' => $_GET['date_to'] ?? date('Y-m-d')
'from' => preg_match('/^\d{4}-\d{2}-\d{2}$/', $uaFrom) ? $uaFrom : date('Y-m-d', strtotime('-30 days')),
'to' => preg_match('/^\d{4}-\d{2}-\d{2}$/', $uaTo) ? $uaTo : date('Y-m-d')
];
// Optimized query using LEFT JOINs with aggregated subqueries instead of correlated subqueries
@@ -377,13 +391,16 @@ switch (true) {
LEFT JOIN (
SELECT user_id, MAX(created_at) as last_activity
FROM audit_log
WHERE DATE(created_at) BETWEEN ? AND ?
GROUP BY user_id
) al ON u.user_id = al.user_id
ORDER BY tickets_created DESC, tickets_resolved DESC";
$stmt = $conn->prepare($sql);
$stmt->bind_param(
'ssssssss',
'ssssssssss',
$dateRange['from'],
$dateRange['to'],
$dateRange['from'],
$dateRange['to'],
$dateRange['from'],
@@ -410,7 +427,7 @@ switch (true) {
header("Location: /");
exit;
case preg_match('/^\/ticket\.php/', $requestPath) && isset($_GET['id']):
case preg_match('/^\/ticket\.php$/', $requestPath) && isset($_GET['id']):
$legacyId = (string)$_GET['id'];
if (ctype_digit($legacyId) && (int)$legacyId > 0) {
header("Location: /ticket/" . $legacyId);
+81
View File
@@ -13,6 +13,14 @@ class ApiKeyAuth
private $userModel;
private $conn;
/**
* Context of the API key validated by the most recent authenticate()/
* verifyOptional() call, or null if none succeeded.
*
* @var array|null
*/
private $keyContext = null;
public function __construct($conn)
{
$this->conn = $conn;
@@ -20,6 +28,57 @@ class ApiKeyAuth
$this->userModel = new UserModel($conn);
}
/**
* Store the validated key's context for later scope/attribution checks.
*
* @param array $keyData Row returned by ApiKeyModel::validateKey()
*/
private function setKeyContext(array $keyData)
{
$this->keyContext = [
'scope' => $keyData['scope'] ?? 'read_write',
'key_name' => $keyData['key_name'] ?? null,
'created_by' => $keyData['created_by'] ?? null,
'api_key_id' => $keyData['api_key_id'] ?? null,
];
}
/**
* Get the context of the authenticated API key.
*
* @return array|null ['scope', 'key_name', 'created_by', 'api_key_id'] or null
*/
public function getKeyContext(): ?array
{
return $this->keyContext;
}
/**
* Enforce that the authenticated key satisfies the required scope.
*
* A 'read' key satisfies only 'read'; a 'read_write' key satisfies both
* 'read' and 'read_write'. On failure a 403 JSON error is sent and the
* script exits.
*
* @param string $needed Required scope ('read' or 'read_write')
*/
public function requireScope(string $needed): void
{
$current = $this->keyContext['scope'] ?? null;
// 'read_write' can do anything; 'read' can only satisfy a 'read' need.
$ok = ($current === 'read_write')
|| ($current === 'read' && $needed === 'read');
if (!$ok) {
$this->sendForbidden(
'API key scope "' . ($current ?? 'none') . '" is insufficient; "'
. $needed . '" is required'
);
exit;
}
}
/**
* Authenticate using API key from Authorization header
*
@@ -52,6 +111,9 @@ class ApiKeyAuth
exit;
}
// Record key context (scope / attribution) for callers to inspect.
$this->setKeyContext($keyData);
// Get system user (or the user who created the key)
$user = $this->userModel->getSystemUser();
@@ -113,6 +175,22 @@ class ApiKeyAuth
]);
}
/**
* Send 403 Forbidden response (e.g. insufficient scope)
*
* @param string $message Error message
*/
private function sendForbidden($message)
{
header('HTTP/1.1 403 Forbidden');
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'Forbidden',
'message' => $message
]);
}
/**
* Verify API key without throwing errors (for optional auth)
*
@@ -137,6 +215,9 @@ class ApiKeyAuth
return null;
}
// Record key context (scope / attribution) for callers to inspect.
$this->setKeyContext($keyData);
$user = $this->userModel->getSystemUser();
if ($user) {
+28 -13
View File
@@ -84,28 +84,43 @@ class RateLimitMiddleware
$ipHash = hash('sha256', $ip . '_' . $type);
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
// Load existing rate data
// Hold an exclusive lock across the whole read-modify-write so concurrent
// requests from the same IP can't both read the same count and each write
// count+1 (which would undercount and let the limit be exceeded).
$fh = @fopen($filePath, 'c+');
if ($fh === false) {
// Can't open the counter file — fail open (don't block legitimate traffic).
return true;
}
if (!flock($fh, LOCK_EX)) {
fclose($fh);
return true;
}
$content = stream_get_contents($fh);
$rateData = ['count' => 0, 'window_start' => $now];
if (file_exists($filePath)) {
$content = @file_get_contents($filePath);
if ($content !== false) {
$decoded = json_decode($content, true);
if (is_array($decoded)) {
$rateData = $decoded;
}
if ($content !== false && $content !== '') {
$decoded = json_decode($content, true);
if (is_array($decoded)) {
$rateData = $decoded;
}
}
// Check if window has expired
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
// Reset when the window has expired
if ($now - ($rateData['window_start'] ?? $now) >= self::WINDOW_SECONDS) {
$rateData = ['count' => 0, 'window_start' => $now];
}
// Increment count
$rateData['count']++;
// Save updated data
@file_put_contents($filePath, json_encode($rateData), LOCK_EX);
// Rewrite the file in place while still holding the lock
rewind($fh);
ftruncate($fh, 0);
fwrite($fh, json_encode($rateData));
fflush($fh);
flock($fh, LOCK_UN);
fclose($fh);
// Check if over limit
return $rateData['count'] <= $limit;
+328
View File
@@ -0,0 +1,328 @@
-- =====================================================================
-- 000_baseline.sql — full schema baseline for tinker_tickets
--
-- Captured from the live production database so the schema is
-- reproducible from source (a fresh install or disaster recovery).
-- Every table uses CREATE TABLE IF NOT EXISTS, so running this against
-- an existing database is a safe no-op. FK checks are disabled during
-- creation so table order does not matter.
-- =====================================================================
SET FOREIGN_KEY_CHECKS = 0;
-- ============ api_keys ============
CREATE TABLE IF NOT EXISTS `api_keys` (
`api_key_id` int(11) NOT NULL AUTO_INCREMENT,
`key_name` varchar(100) NOT NULL,
`key_hash` varchar(255) NOT NULL,
`key_prefix` varchar(20) NOT NULL,
`is_active` tinyint(1) DEFAULT 1,
`scope` enum('read','read_write') NOT NULL DEFAULT 'read_write',
`created_by` int(11) DEFAULT NULL,
`last_used` timestamp NULL DEFAULT NULL,
`expires_at` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`api_key_id`),
UNIQUE KEY `key_hash` (`key_hash`),
KEY `created_by` (`created_by`),
KEY `idx_key_hash` (`key_hash`),
KEY `idx_is_active` (`is_active`),
CONSTRAINT `api_keys_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ audit_log ============
CREATE TABLE IF NOT EXISTS `audit_log` (
`audit_id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` int(11) DEFAULT NULL,
`action_type` varchar(50) NOT NULL,
`entity_type` varchar(50) NOT NULL,
`entity_id` varchar(50) DEFAULT NULL,
`details` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`details`)),
`ip_address` varchar(45) DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`audit_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_created_at` (`created_at`),
KEY `idx_entity` (`entity_type`,`entity_id`),
KEY `idx_action_type` (`action_type`),
KEY `idx_audit_log_user_created` (`user_id`,`created_at` DESC),
KEY `idx_audit_log_action_type` (`action_type`,`created_at` DESC),
KEY `idx_audit_entity` (`entity_type`,`entity_id`),
KEY `idx_audit_user` (`user_id`,`created_at`),
CONSTRAINT `audit_log_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ bulk_operations ============
CREATE TABLE IF NOT EXISTS `bulk_operations` (
`operation_id` int(11) NOT NULL AUTO_INCREMENT,
`operation_type` varchar(50) NOT NULL,
`ticket_ids` text NOT NULL,
`performed_by` int(11) DEFAULT NULL,
`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)
`status` varchar(32) DEFAULT 'pending',
`total_tickets` int(11) DEFAULT NULL,
`processed_tickets` int(11) DEFAULT 0,
`failed_tickets` int(11) DEFAULT 0,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`completed_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`operation_id`),
KEY `idx_performed_by` (`performed_by`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `bulk_operations_ibfk_1` FOREIGN KEY (`performed_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ custom_field_definitions ============
CREATE TABLE IF NOT EXISTS `custom_field_definitions` (
`field_id` int(11) NOT NULL AUTO_INCREMENT,
`field_name` varchar(100) NOT NULL,
`field_label` varchar(255) NOT NULL,
`field_type` enum('text','textarea','select','checkbox','date','number') NOT NULL,
`field_options` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Options for select fields: {"options": ["Option 1", "Option 2"]}' CHECK (json_valid(`field_options`)),
`category` varchar(50) DEFAULT NULL COMMENT 'NULL = applies to all categories',
`is_required` tinyint(1) DEFAULT 0,
`display_order` int(11) DEFAULT 0,
`is_active` tinyint(1) DEFAULT 1,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`field_id`),
KEY `idx_custom_fields_category` (`category`,`is_active`),
KEY `idx_custom_fields_order` (`display_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ custom_field_values ============
CREATE TABLE IF NOT EXISTS `custom_field_values` (
`value_id` int(11) NOT NULL AUTO_INCREMENT,
`ticket_id` varchar(9) NOT NULL,
`field_id` int(11) NOT NULL,
`field_value` text DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`value_id`),
UNIQUE KEY `unique_ticket_field` (`ticket_id`,`field_id`),
KEY `field_id` (`field_id`),
KEY `idx_custom_values_ticket` (`ticket_id`),
CONSTRAINT `custom_field_values_ibfk_1` FOREIGN KEY (`field_id`) REFERENCES `custom_field_definitions` (`field_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ migrations ============
CREATE TABLE IF NOT EXISTS `migrations` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`filename` varchar(255) NOT NULL,
`applied_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `filename` (`filename`),
KEY `idx_filename` (`filename`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ recurring_tickets ============
CREATE TABLE IF NOT EXISTS `recurring_tickets` (
`recurring_id` int(11) NOT NULL AUTO_INCREMENT,
`title_template` varchar(255) NOT NULL,
`description_template` text DEFAULT NULL,
`category` varchar(50) DEFAULT 'General',
`type` varchar(50) DEFAULT 'Task',
`priority` int(11) DEFAULT 4,
`assigned_to` int(11) DEFAULT NULL,
`schedule_type` enum('daily','weekly','monthly') NOT NULL,
`schedule_day` int(11) DEFAULT NULL COMMENT 'Day of week (1-7) for weekly, day of month (1-31) for monthly',
`schedule_time` time DEFAULT '09:00:00',
`next_run_at` timestamp NOT NULL,
`last_run_at` timestamp NULL DEFAULT NULL,
`is_active` tinyint(1) DEFAULT 1,
`created_by` int(11) DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`recurring_id`),
KEY `assigned_to` (`assigned_to`),
KEY `created_by` (`created_by`),
KEY `idx_recurring_next_run` (`next_run_at`,`is_active`),
KEY `idx_recurring_active` (`is_active`),
CONSTRAINT `recurring_tickets_ibfk_1` FOREIGN KEY (`assigned_to`) REFERENCES `users` (`user_id`) ON DELETE SET NULL,
CONSTRAINT `recurring_tickets_ibfk_2` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ saved_filters ============
CREATE TABLE IF NOT EXISTS `saved_filters` (
`filter_id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`filter_name` varchar(100) NOT NULL,
`filter_criteria` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL CHECK (json_valid(`filter_criteria`)),
`is_default` tinyint(1) DEFAULT 0,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`filter_id`),
UNIQUE KEY `unique_user_filter_name` (`user_id`,`filter_name`),
KEY `idx_user_filters` (`user_id`,`is_default`),
CONSTRAINT `saved_filters_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ status_transitions ============
CREATE TABLE IF NOT EXISTS `status_transitions` (
`transition_id` int(11) NOT NULL AUTO_INCREMENT,
`from_status` varchar(50) NOT NULL,
`to_status` varchar(50) NOT NULL,
`requires_comment` tinyint(1) DEFAULT 0,
`requires_admin` tinyint(1) DEFAULT 0,
`is_active` tinyint(1) DEFAULT 1,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`transition_id`),
UNIQUE KEY `unique_transition` (`from_status`,`to_status`),
KEY `idx_from_status` (`from_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ ticket_attachments ============
CREATE TABLE IF NOT EXISTS `ticket_attachments` (
`attachment_id` int(11) NOT NULL AUTO_INCREMENT,
`ticket_id` varchar(9) NOT NULL,
`filename` varchar(255) NOT NULL,
`original_filename` varchar(255) NOT NULL,
`file_size` int(11) NOT NULL,
`mime_type` varchar(100) NOT NULL,
`uploaded_by` int(11) DEFAULT NULL,
`uploaded_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`attachment_id`),
KEY `idx_attachments_ticket` (`ticket_id`),
KEY `idx_attachments_uploaded_by` (`uploaded_by`),
CONSTRAINT `ticket_attachments_ibfk_1` FOREIGN KEY (`uploaded_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ ticket_comments ============
CREATE TABLE IF NOT EXISTS `ticket_comments` (
`comment_id` int(11) NOT NULL AUTO_INCREMENT,
`parent_comment_id` int(11) DEFAULT NULL,
`thread_depth` tinyint(3) unsigned NOT NULL DEFAULT 0,
`ticket_id` varchar(10) DEFAULT NULL,
`user_name` varchar(50) DEFAULT NULL,
`comment_text` text DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`markdown_enabled` tinyint(1) DEFAULT 0,
`user_id` int(11) DEFAULT NULL,
PRIMARY KEY (`comment_id`),
KEY `fk_comments_user_id` (`user_id`),
KEY `idx_comments_ticket_created` (`ticket_id`,`created_at` DESC),
KEY `idx_parent_comment` (`parent_comment_id`),
CONSTRAINT `fk_comments_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE SET NULL,
CONSTRAINT `fk_parent_comment` FOREIGN KEY (`parent_comment_id`) REFERENCES `ticket_comments` (`comment_id`) ON DELETE CASCADE,
CONSTRAINT `ticket_comments_ibfk_1` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`ticket_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ ticket_dependencies ============
CREATE TABLE IF NOT EXISTS `ticket_dependencies` (
`dependency_id` int(11) NOT NULL AUTO_INCREMENT,
`ticket_id` varchar(9) NOT NULL,
`depends_on_id` varchar(9) NOT NULL,
`dependency_type` enum('blocks','blocked_by','relates_to','duplicates') DEFAULT 'blocks',
`created_by` int(11) DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`dependency_id`),
UNIQUE KEY `unique_dependency` (`ticket_id`,`depends_on_id`,`dependency_type`),
KEY `idx_ticket_id` (`ticket_id`),
KEY `idx_depends_on_id` (`depends_on_id`),
KEY `created_by` (`created_by`),
CONSTRAINT `ticket_dependencies_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ ticket_templates ============
CREATE TABLE IF NOT EXISTS `ticket_templates` (
`template_id` int(11) NOT NULL AUTO_INCREMENT,
`template_name` varchar(100) NOT NULL,
`title_template` varchar(255) NOT NULL,
`description_template` text NOT NULL,
`category` varchar(50) DEFAULT NULL,
`type` varchar(50) DEFAULT NULL,
`default_priority` int(11) DEFAULT 4,
`created_by` int(11) DEFAULT NULL,
`is_active` tinyint(1) DEFAULT 1,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`template_id`),
KEY `created_by` (`created_by`),
KEY `idx_template_name` (`template_name`),
CONSTRAINT `ticket_templates_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ ticket_watchers ============
CREATE TABLE IF NOT EXISTS `ticket_watchers` (
`ticket_id` int(11) NOT NULL,
`user_id` int(11) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`ticket_id`,`user_id`),
KEY `idx_watcher_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ tickets ============
CREATE TABLE IF NOT EXISTS `tickets` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`ticket_id` varchar(9) NOT NULL,
`title` varchar(255) NOT NULL,
`category` varchar(100) DEFAULT NULL,
`type` varchar(100) DEFAULT NULL,
`visibility` enum('public','internal','confidential') DEFAULT 'public',
`visibility_groups` varchar(500) DEFAULT NULL,
`status` varchar(20) NOT NULL DEFAULT 'Open',
`description` text DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`closed_at` timestamp NULL DEFAULT NULL,
`priority` int(11) NOT NULL DEFAULT 1 CHECK (`priority` between 1 and 6),
`hash` varchar(64) DEFAULT NULL,
`created_by` int(11) DEFAULT NULL,
`updated_by` int(11) DEFAULT NULL,
`assigned_to` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ticket_id` (`ticket_id`),
UNIQUE KEY `unique_hash` (`hash`),
KEY `fk_tickets_updated_by` (`updated_by`),
KEY `idx_status` (`status`),
KEY `idx_priority` (`priority`),
KEY `idx_tickets_created_at` (`created_at`),
KEY `idx_assigned_to` (`assigned_to`),
KEY `idx_tickets_status` (`status`),
KEY `idx_tickets_status_priority_created` (`status`,`priority`,`created_at` DESC),
KEY `idx_tickets_visibility` (`visibility`),
KEY `idx_tickets_category` (`category`),
KEY `idx_tickets_type` (`type`),
KEY `idx_tickets_priority` (`priority`),
KEY `idx_tickets_updated_at` (`updated_at`),
KEY `idx_tickets_created_by` (`created_by`),
KEY `idx_tickets_assigned_to` (`assigned_to`),
KEY `idx_tickets_status_created` (`status`,`created_at`),
KEY `idx_tickets_assigned_status` (`assigned_to`,`status`),
KEY `idx_tickets_visibility_status` (`visibility`,`status`),
KEY `idx_tickets_closed_at` (`closed_at`),
FULLTEXT KEY `ft_title_description` (`title`,`description`),
CONSTRAINT `fk_tickets_assigned_to` FOREIGN KEY (`assigned_to`) REFERENCES `users` (`user_id`) ON DELETE SET NULL,
CONSTRAINT `fk_tickets_created_by` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL,
CONSTRAINT `fk_tickets_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ user_preferences ============
CREATE TABLE IF NOT EXISTS `user_preferences` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`preference_key` varchar(100) NOT NULL,
`preference_value` text DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `unique_user_pref` (`user_id`,`preference_key`),
KEY `idx_user_preferences_user_key` (`user_id`,`preference_key`),
CONSTRAINT `user_preferences_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ users ============
CREATE TABLE IF NOT EXISTS `users` (
`user_id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(100) NOT NULL,
`display_name` varchar(255) DEFAULT NULL,
`email` varchar(255) DEFAULT NULL,
`groups` text DEFAULT NULL,
`is_admin` tinyint(1) DEFAULT 0,
`last_login` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`user_id`),
UNIQUE KEY `username` (`username`),
KEY `idx_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
SET FOREIGN_KEY_CHECKS = 1;
@@ -0,0 +1,12 @@
-- Widen bulk_operations.status
--
-- The code writes 'completed_with_errors' (21 chars) when a bulk operation
-- finishes with per-ticket failures, but the column was varchar(20), so the
-- write failed with "Data too long for column 'status'". This was unreachable
-- while bulk status changes forced every transition through; now that they
-- honour the Workflow Designer, partial failures are a normal outcome.
--
-- Safe to re-run.
ALTER TABLE `bulk_operations`
MODIFY COLUMN `status` varchar(32) DEFAULT 'pending';
@@ -0,0 +1,30 @@
-- Fix collation inconsistency on saved_filters and ticket_attachments
--
-- README.md Developer Notes #12: "Database collation: Use
-- utf8mb4_general_ci (not unicode_ci) for new tables." These two tables
-- were created with utf8mb4_unicode_ci instead, inconsistent with every
-- other table in the schema. Mixed collations don't break anything by
-- themselves, but any future query joining/comparing these columns
-- against general_ci columns needs explicit COLLATE casts or hits
-- "Illegal mix of collations" errors.
--
-- Safe to re-run.
ALTER TABLE `saved_filters`
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
-- saved_filters.filter_criteria is pinned to utf8mb4_bin (for the
-- json_valid() CHECK constraint) — restore that after the table-wide
-- CONVERT TO above, which resets it to general_ci. MariaDB drops the
-- inline CHECK when the column is MODIFYed, so re-add it explicitly.
ALTER TABLE `saved_filters`
MODIFY COLUMN `filter_criteria` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL;
ALTER TABLE `saved_filters`
DROP CONSTRAINT IF EXISTS `saved_filters_filter_criteria_json`;
ALTER TABLE `saved_filters`
ADD CONSTRAINT `saved_filters_filter_criteria_json` CHECK (json_valid(`filter_criteria`));
ALTER TABLE `ticket_attachments`
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
+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;
+56 -9
View File
@@ -18,10 +18,19 @@ class ApiKeyModel
* @param string $keyName Descriptive name for the key
* @param int $createdBy User ID who created the key
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error'
* @param string $scope Access scope: 'read' or 'read_write' (default 'read_write')
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'scope', 'error'
*/
public function createKey($keyName, $createdBy, $expiresInDays = null)
public function createKey($keyName, $createdBy, $expiresInDays = null, $scope = 'read_write')
{
// Validate the requested scope — only the two known values are allowed
if (!in_array($scope, ['read', 'read_write'], true)) {
return [
'success' => false,
'error' => "Invalid scope: must be 'read' or 'read_write'"
];
}
// Generate random API key (32 bytes = 64 hex characters)
$apiKey = bin2hex(random_bytes(32));
@@ -39,9 +48,10 @@ class ApiKeyModel
// Insert API key into database
$stmt = $this->conn->prepare(
"INSERT INTO api_keys (key_name, key_hash, key_prefix, created_by, expires_at) VALUES (?, ?, ?, ?, ?)"
"INSERT INTO api_keys (key_name, key_hash, key_prefix, scope, created_by, expires_at) "
. "VALUES (?, ?, ?, ?, ?, ?)"
);
$stmt->bind_param("sssis", $keyName, $keyHash, $keyPrefix, $createdBy, $expiresAt);
$stmt->bind_param("ssssis", $keyName, $keyHash, $keyPrefix, $scope, $createdBy, $expiresAt);
if ($stmt->execute()) {
$keyId = $this->conn->insert_id;
@@ -52,6 +62,7 @@ class ApiKeyModel
'api_key' => $apiKey, // Return plaintext key ONCE
'key_prefix' => $keyPrefix,
'key_id' => $keyId,
'scope' => $scope,
'expires_at' => $expiresAt
];
} else {
@@ -96,6 +107,13 @@ class ApiKeyModel
$keyData = $result->fetch_assoc();
$stmt->close();
// Ensure a scope is always present. On an un-migrated database the column
// does not exist yet (or is null), in which case we treat the key as
// full-access so existing integrations keep working.
if (!isset($keyData['scope']) || $keyData['scope'] === null || $keyData['scope'] === '') {
$keyData['scope'] = 'read_write';
}
// Check expiration
if ($keyData['expires_at'] !== null) {
$expiresAt = strtotime($keyData['expires_at']);
@@ -156,18 +174,41 @@ class ApiKeyModel
}
/**
* Get all API keys (for admin panel)
* Get a page of API keys (for admin panel)
*
* @return array Array of API key records (without hashes)
* Active keys are listed first, then newest first within each group.
*
* @param int $page 1-based page number
* @param int $perPage Rows per page
* @return array ['keys' => array, 'total' => int, 'page' => int, 'perPage' => int]
*/
public function getAllKeys()
public function getAllKeys($page = 1, $perPage = 20)
{
// Normalise pagination inputs
$page = max(1, (int)$page);
$perPage = (int)$perPage;
if ($perPage < 1) {
$perPage = 20;
}
$offset = ($page - 1) * $perPage;
// Total count for pagination controls
$total = 0;
$countResult = $this->conn->query("SELECT COUNT(*) AS total FROM api_keys");
if ($countResult) {
$countRow = $countResult->fetch_assoc();
$total = (int)($countRow['total'] ?? 0);
$countResult->free();
}
$stmt = $this->conn->prepare(
"SELECT ak.*, u.username, u.display_name
FROM api_keys ak
LEFT JOIN users u ON ak.created_by = u.user_id
ORDER BY ak.created_at DESC"
ORDER BY ak.is_active DESC, ak.created_at DESC
LIMIT ? OFFSET ?"
);
$stmt->bind_param("ii", $perPage, $offset);
$stmt->execute();
$result = $stmt->get_result();
@@ -179,7 +220,13 @@ class ApiKeyModel
}
$stmt->close();
return $keys;
return [
'keys' => $keys,
'total' => $total,
'page' => $page,
'perPage' => $perPage
];
}
/**
+56 -23
View File
@@ -19,13 +19,15 @@ class AuditLogModel
/** @var array Allowed action types for filtering */
private const VALID_ACTION_TYPES = [
'create', 'update', 'delete', 'view', 'security_event',
'login', 'logout', 'assign', 'comment', 'bulk_update'
'login', 'logout', 'assign', 'unassign', 'comment', 'mention',
'revoke', 'attachment_upload', 'attachment_delete', 'bulk_update'
];
/** @var array Allowed entity types for filtering */
private const VALID_ENTITY_TYPES = [
'ticket', 'comment', 'user', 'api_key', 'security',
'template', 'attachment', 'group'
'template', 'attachment', 'ticket_attachments', 'group',
'dependency', 'workflow_transition', 'recurring_ticket', 'custom_field'
];
public function __construct($conn)
@@ -307,17 +309,28 @@ class AuditLogModel
* @param int $daysToKeep Number of days of logs to keep
* @return int Number of deleted records
*/
public function deleteOldLogs($daysToKeep = 90)
public function deleteOldLogs($daysToKeep = 90, $batchSize = 1000)
{
// Batched to bound how long each statement holds row locks — an
// unbounded single DELETE on a large backlog (e.g. the first run after
// enabling/changing retention, or after the cron silently missed runs)
// would otherwise contend with the frequent concurrent INSERTs the
// audit log receives from live traffic.
$stmt = $this->conn->prepare(
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY audit_id LIMIT ?"
);
$stmt->bind_param("i", $daysToKeep);
$stmt->execute();
$affectedRows = $stmt->affected_rows;
$stmt->bind_param("ii", $daysToKeep, $batchSize);
$totalDeleted = 0;
do {
$stmt->execute();
$affected = $stmt->affected_rows;
$totalDeleted += $affected;
} while ($affected > 0);
$stmt->close();
return $affectedRows;
return $totalDeleted;
}
/**
@@ -327,24 +340,44 @@ class AuditLogModel
*/
private function getClientIP()
{
$ipAddress = '';
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '';
// Check for proxy headers
if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
// Cloudflare
$ipAddress = $_SERVER['HTTP_CF_CONNECTING_IP'];
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
// Nginx proxy
$ipAddress = $_SERVER['HTTP_X_REAL_IP'];
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
// Standard proxy header
$ipAddress = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0];
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
// Direct connection
$ipAddress = $_SERVER['REMOTE_ADDR'];
// Forwarded/proxy headers are client-controlled, so only believe them when
// the request actually came from a trusted reverse proxy (same rule as
// RateLimitMiddleware). Otherwise a client could forge its audit-log IP.
$trusted = $GLOBALS['config']['TRUSTED_PROXIES'] ?? [];
if (empty($trusted) || !in_array($remoteAddr, $trusted, true)) {
return trim($remoteAddr);
}
return trim($ipAddress);
// Cloudflare sets CF-Connecting-IP to the real client.
if (
!empty($_SERVER['HTTP_CF_CONNECTING_IP'])
&& filter_var($_SERVER['HTTP_CF_CONNECTING_IP'], FILTER_VALIDATE_IP)
) {
return trim($_SERVER['HTTP_CF_CONNECTING_IP']);
}
// The trusted proxy appends the connecting client to X-Forwarded-For, so
// the RIGHTMOST entry is the IP it observed (any client-supplied prefix is
// not trustworthy).
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
$ip = trim(end($ips));
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
// X-Real-IP is set by the proxy itself.
if (
!empty($_SERVER['HTTP_X_REAL_IP'])
&& filter_var($_SERVER['HTTP_X_REAL_IP'], FILTER_VALIDATE_IP)
) {
return trim($_SERVER['HTTP_X_REAL_IP']);
}
return trim($remoteAddr);
}
/**
+205 -8
View File
@@ -7,11 +7,47 @@ class BulkOperationsModel
{
private $conn;
/** @var WorkflowModel|null Lazily created; only needed by status-changing operations */
private $workflowModel = null;
/** @var CommentModel|null Lazily created; only needed when a status change carries a comment */
private $commentModel = null;
/** @var array<int,string> user_id → display name, resolved once per request */
private $userNames = [];
public function __construct($conn)
{
$this->conn = $conn;
}
/**
* Workflow model, created on first use.
*/
private function workflow(): WorkflowModel
{
if ($this->workflowModel === null) {
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
$this->workflowModel = new WorkflowModel($this->conn);
}
return $this->workflowModel;
}
/**
* The status a bulk operation is trying to move tickets into, or null for
* operations that don't change status.
*/
private function targetStatusFor(string $operationType, array $parameters): ?string
{
if ($operationType === 'bulk_close') {
return 'Closed';
}
if ($operationType === 'bulk_status') {
return isset($parameters['status']) ? (string)$parameters['status'] : null;
}
return null;
}
/**
* Create a new bulk operation record
*
@@ -77,6 +113,15 @@ class BulkOperationsModel
$ticketIds = explode(',', $operation['ticket_ids']);
$parameters = $operation['parameters'] ? json_decode($operation['parameters'], true) : [];
// Validate operation parameters up front so invalid values (out-of-range
// priority, unknown status, nonexistent assignee) are rejected cleanly
// instead of corrupting tickets or throwing mid-transaction.
$paramError = $this->validateOperationParameters($operation['operation_type'], is_array($parameters) ? $parameters : []);
if ($paramError !== null) {
return ['processed' => 0, 'failed' => count($ticketIds), 'error' => $paramError];
}
$processed = 0;
$failed = 0;
$errors = [];
@@ -91,6 +136,30 @@ class BulkOperationsModel
// Batch load all tickets in one query to eliminate N+1 problem
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
// Status-changing operations honour the Workflow Designer. If any ticket in
// the selection needs a comment for its transition, reject the whole batch
// before mutating anything so the client can collect one — a partially
// applied batch is worse than none.
$targetStatus = $this->targetStatusFor($operation['operation_type'], is_array($parameters) ? $parameters : []);
$bulkComment = trim((string)($parameters['comment'] ?? ''));
if ($targetStatus !== null && $bulkComment === '') {
foreach ($ticketIds as $tid) {
$t = $ticketsById[trim($tid)] ?? null;
if (!$t || $t['status'] === $targetStatus) {
continue;
}
if ($this->workflow()->transitionRequiresComment($t['status'], $targetStatus)) {
return [
'processed' => 0,
'failed' => count($ticketIds),
'error' => 'A comment is required to change status from '
. $t['status'] . ' → ' . $targetStatus,
'requires_comment' => true,
];
}
}
}
// Start transaction for data consistency
$this->conn->begin_transaction();
@@ -104,6 +173,32 @@ class BulkOperationsModel
$success = false;
try {
// bulk_status / bulk_close enforce the same Workflow Designer
// rules as the single-ticket path: a transition the designer
// doesn't define is refused, and requires_comment is honoured
// (checked up front, above). requires_admin is satisfied because
// api/bulk_operation.php already gates the endpoint on admin.
if ($targetStatus !== null) {
$currentTicket = $ticketsById[$ticketId] ?? null;
if ($currentTicket && $currentTicket['status'] === $targetStatus) {
// Already in the requested state — nothing to do, and
// reporting a no-op as a failure would just confuse.
$processed++;
continue;
}
$allowed = $currentTicket === null || $this->workflow()->isTransitionAllowed(
$currentTicket['status'],
$targetStatus,
true
);
if (!$allowed) {
$failed++;
$errors[] = "Ticket $ticketId: transition not allowed ("
. $currentTicket['status'] . ' → ' . $targetStatus . ')';
continue;
}
}
switch ($operation['operation_type']) {
case 'bulk_close':
// Get current ticket from pre-loaded batch
@@ -218,6 +313,12 @@ class BulkOperationsModel
}
if ($success) {
// Persist the status-change reason as a real comment, so a
// bulk close is as auditable on the ticket as a single close
// (where the client posts the comment before updating).
if ($targetStatus !== null && $bulkComment !== '') {
$this->postBulkComment($ticketId, (int)$operation['performed_by'], $bulkComment);
}
$processed++;
} else {
$failed++;
@@ -276,14 +377,22 @@ class BulkOperationsModel
];
}
// Update operation status
$status = $failed > 0 ? 'completed_with_errors' : 'completed';
$sql = "UPDATE bulk_operations SET status = ?, processed_tickets = ?, failed_tickets = ?,
completed_at = NOW() WHERE operation_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("siii", $status, $processed, $failed, $operationId);
$stmt->execute();
$stmt->close();
// Update operation status. This is bookkeeping only and runs after the
// ticket changes are committed, so a failure here (e.g. the status column
// not yet widened by 001_widen_bulk_operations_status.sql on an instance
// deployed ahead of its migrations) must not turn a completed operation
// into an error response.
try {
$status = $failed > 0 ? 'completed_with_errors' : 'completed';
$sql = "UPDATE bulk_operations SET status = ?, processed_tickets = ?, failed_tickets = ?,
completed_at = NOW() WHERE operation_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("siii", $status, $processed, $failed, $operationId);
$stmt->execute();
$stmt->close();
} catch (Throwable $e) {
error_log("Bulk operation $operationId completed but status bookkeeping failed: " . $e->getMessage());
}
$result = ['processed' => $processed, 'failed' => $failed];
if (!empty($errors)) {
@@ -292,6 +401,94 @@ class BulkOperationsModel
return $result;
}
/**
* Validate the parameters for a bulk operation before any ticket is mutated.
*
* @return string|null Error message, or null if the parameters are valid
*/
private function validateOperationParameters(string $type, array $parameters): ?string
{
switch ($type) {
case 'bulk_priority':
if (!isset($parameters['priority'])) {
return 'Missing priority parameter';
}
$priority = $parameters['priority'];
// tickets.priority has a CHECK constraint (between 1 and 6).
if (!is_numeric($priority) || (int)$priority < 1 || (int)$priority > 6) {
return 'Invalid priority: must be between 1 and 6';
}
break;
case 'bulk_status':
if (!isset($parameters['status'])) {
return 'Missing status parameter';
}
$validStatuses = $GLOBALS['config']['TICKET_STATUSES']
?? ['Open', 'Pending', 'In Progress', 'Closed'];
if (!in_array($parameters['status'], $validStatuses, true)) {
return 'Invalid status value';
}
break;
case 'bulk_assign':
if (!isset($parameters['assigned_to'])) {
return 'Missing assigned_to parameter';
}
$assignedTo = $parameters['assigned_to'];
if (!is_numeric($assignedTo) || (int)$assignedTo <= 0 || !$this->userExists((int)$assignedTo)) {
return 'Invalid assigned_to: user does not exist';
}
break;
}
return null;
}
/**
* Post the bulk status-change reason as a comment on one ticket.
*
* Runs inside the caller's transaction, so a rollback drops the comment along
* with the status change.
*/
private function postBulkComment(string $ticketId, int $userId, string $text): void
{
require_once dirname(__DIR__) . '/models/CommentModel.php';
if ($this->commentModel === null) {
$this->commentModel = new CommentModel($this->conn);
}
if (!isset($this->userNames[$userId])) {
$stmt = $this->conn->prepare(
"SELECT COALESCE(NULLIF(display_name, ''), username) AS name FROM users WHERE user_id = ? LIMIT 1"
);
$stmt->bind_param("i", $userId);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
$this->userNames[$userId] = $row['name'] ?? 'User';
}
$this->commentModel->addComment($ticketId, [
'user_name' => $this->userNames[$userId],
'comment_text' => $text,
'markdown_enabled' => 0,
], $userId);
}
/**
* Check whether a user ID exists.
*/
private function userExists(int $userId): bool
{
$stmt = $this->conn->prepare("SELECT 1 FROM users WHERE user_id = ? LIMIT 1");
$stmt->bind_param("i", $userId);
$stmt->execute();
$exists = $stmt->get_result()->num_rows > 0;
$stmt->close();
return $exists;
}
/**
* Get bulk operation by ID
*
+44 -28
View File
@@ -58,12 +58,12 @@ class CommentModel
/**
* Get total comment count for a ticket
*/
public function getCommentCount(int $ticketId): int
public function getCommentCount(string $ticketId): int
{
$stmt = $this->conn->prepare(
"SELECT COUNT(*) as total FROM ticket_comments WHERE ticket_id = ?"
);
$stmt->bind_param("i", $ticketId);
$stmt->bind_param("s", $ticketId);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
@@ -108,9 +108,9 @@ class CommentModel
$stmt = $this->conn->prepare($sql);
if ($limit > 0) {
$stmt->bind_param("iii", $ticketId, $limit, $offset);
$stmt->bind_param("sii", $ticketId, $limit, $offset);
} else {
$stmt->bind_param("i", $ticketId);
$stmt->bind_param("s", $ticketId);
}
$stmt->execute();
$result = $stmt->get_result();
@@ -146,7 +146,7 @@ class CommentModel
/**
* Paginated threaded comments: fetch one page of root comments + all their replies.
*/
private function getThreadedCommentsPaged(int $ticketId, int $limit, int $offset): array
private function getThreadedCommentsPaged(string $ticketId, int $limit, int $offset): array
{
// Page of root comments
$rootSql = "SELECT tc.*, u.display_name, u.username
@@ -156,7 +156,7 @@ class CommentModel
ORDER BY tc.created_at DESC
LIMIT ? OFFSET ?";
$stmt = $this->conn->prepare($rootSql);
$stmt->bind_param("iii", $ticketId, $limit, $offset);
$stmt->bind_param("sii", $ticketId, $limit, $offset);
$stmt->execute();
$rootResult = $stmt->get_result();
$stmt->close();
@@ -176,27 +176,41 @@ class CommentModel
return [];
}
// All replies for these root comments (up to 3 levels deep)
$placeholders = implode(',', array_fill(0, count($rootIds), '?'));
$replySql = "SELECT tc.*, u.display_name, u.username
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.user_id
WHERE tc.ticket_id = ?
AND tc.parent_comment_id IN ($placeholders)
AND tc.parent_comment_id IS NOT NULL
ORDER BY tc.created_at ASC";
$replyStmt = $this->conn->prepare($replySql);
$types = 'i' . str_repeat('i', count($rootIds));
$replyStmt->bind_param($types, $ticketId, ...$rootIds);
$replyStmt->execute();
$replyResult = $replyStmt->get_result();
$replyStmt->close();
// Load replies level-by-level under this page's roots. A single
// "parent_comment_id IN (rootIds)" only fetches DIRECT children, so
// grandchildren/great-grandchildren (addComment allows up to depth 3)
// would be missing from the map and dropped by buildCommentThread.
// Expand iteratively until no new replies (bounded by max depth 3).
$parentIds = $rootIds;
$depth = 0;
while (!empty($parentIds) && $depth < 3) {
$placeholders = implode(',', array_fill(0, count($parentIds), '?'));
$replySql = "SELECT tc.*, u.display_name, u.username
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.user_id
WHERE tc.ticket_id = ?
AND tc.parent_comment_id IN ($placeholders)
ORDER BY tc.created_at ASC";
$replyStmt = $this->conn->prepare($replySql);
$types = 's' . str_repeat('i', count($parentIds));
$replyStmt->bind_param($types, $ticketId, ...$parentIds);
$replyStmt->execute();
$replyResult = $replyStmt->get_result();
$replyStmt->close();
while ($row = $replyResult->fetch_assoc()) {
$row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User');
$row['replies'] = [];
$row['thread_depth'] = $row['thread_depth'] ?? 1;
$commentMap[$row['comment_id']] = $row;
$nextParentIds = [];
while ($row = $replyResult->fetch_assoc()) {
if (isset($commentMap[$row['comment_id']])) {
continue; // guard against cycles / duplicates
}
$row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User');
$row['replies'] = [];
$row['thread_depth'] = $depth + 1;
$commentMap[$row['comment_id']] = $row;
$nextParentIds[] = $row['comment_id'];
}
$parentIds = $nextParentIds;
$depth++;
}
$rootComments = [];
@@ -380,7 +394,8 @@ class CommentModel
'updated_at' => $hasUpdatedAt ? date('M d, Y H:i') : null
];
} else {
return ['success' => false, 'error' => $this->conn->error];
error_log('CommentModel::updateComment failed: ' . $this->conn->error);
return ['success' => false, 'error' => 'Failed to update comment'];
}
}
@@ -414,7 +429,8 @@ class CommentModel
'ticket_id' => $ticketId
];
} else {
return ['success' => false, 'error' => $this->conn->error];
error_log('CommentModel::deleteComment failed: ' . $this->conn->error);
return ['success' => false, 'error' => 'Failed to delete comment'];
}
}
}
+14 -6
View File
@@ -96,6 +96,10 @@ class CustomFieldModel
(field_name, field_label, field_type, field_options, category, is_required, display_order, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
$isRequired = $data['is_required'] ?? 0;
$displayOrder = $data['display_order'] ?? 0;
$isActive = $data['is_active'] ?? 1;
$stmt = $this->conn->prepare($sql);
$stmt->bind_param(
'sssssiii',
@@ -104,9 +108,9 @@ class CustomFieldModel
$data['field_type'],
$options,
$data['category'],
$data['is_required'] ?? 0,
$data['display_order'] ?? 0,
$data['is_active'] ?? 1
$isRequired,
$displayOrder,
$isActive
);
if ($stmt->execute()) {
@@ -135,6 +139,10 @@ class CustomFieldModel
category = ?, is_required = ?, display_order = ?, is_active = ?
WHERE field_id = ?";
$isRequired = $data['is_required'] ?? 0;
$displayOrder = $data['display_order'] ?? 0;
$isActive = $data['is_active'] ?? 1;
$stmt = $this->conn->prepare($sql);
$stmt->bind_param(
'sssssiiii',
@@ -143,9 +151,9 @@ class CustomFieldModel
$data['field_type'],
$options,
$data['category'],
$data['is_required'] ?? 0,
$data['display_order'] ?? 0,
$data['is_active'] ?? 1,
$isRequired,
$displayOrder,
$isActive,
$fieldId
);
+104 -13
View File
@@ -12,25 +12,67 @@ class DependencyModel
$this->conn = $conn;
}
/**
* Build the extra WHERE fragment (and bound params) that restricts the joined
* ticket alias `t` to tickets the requesting user may see. Reuses
* TicketModel::getVisibilityFilter so the rules stay in one place.
*
* @return array{sql:string,types:string,params:array}
*/
private function buildVisibilityClause($userId, array $userGroups, $isAdmin): array
{
if ($isAdmin) {
return ['sql' => '', 'types' => '', 'params' => []];
}
require_once dirname(__DIR__) . '/models/TicketModel.php';
$ticketModel = new TicketModel($this->conn);
$filter = $ticketModel->getVisibilityFilter([
'user_id' => (int)$userId,
'groups' => implode(',', $userGroups),
'is_admin' => false,
]);
if ($filter['sql'] === '1=1' || $filter['sql'] === '') {
return ['sql' => '', 'types' => '', 'params' => []];
}
return [
'sql' => ' AND ' . $filter['sql'],
'types' => $filter['types'],
'params' => $filter['params'],
];
}
/**
* Get all dependencies for a ticket
*
* The linked ticket's title/status/priority are only returned for tickets the
* requesting user is allowed to see (same rules as TicketModel::getVisibilityFilter).
* With the default (null user, non-admin) only public tickets are exposed.
*
* @param string $ticketId Ticket ID
* @param int|null $userId Requesting user's ID (null = anonymous)
* @param array $userGroups Requesting user's group names
* @param bool $isAdmin Whether the requesting user is an admin (bypasses filtering)
* @return array Dependencies grouped by type
*/
public function getDependencies($ticketId)
public function getDependencies($ticketId, $userId = null, array $userGroups = [], $isAdmin = false)
{
$visibility = $this->buildVisibilityClause($userId, $userGroups, $isAdmin);
$sql = "SELECT d.*, t.title, t.status, t.priority
FROM ticket_dependencies d
LEFT JOIN tickets t ON d.depends_on_id = t.ticket_id
WHERE d.ticket_id = ?
WHERE d.ticket_id = ?" . $visibility['sql'] . "
ORDER BY d.dependency_type, d.created_at DESC";
$stmt = $this->conn->prepare($sql);
if (!$stmt) {
throw new Exception('Prepare failed: ' . $this->conn->error);
}
$stmt->bind_param("s", $ticketId);
$types = 's' . $visibility['types'];
$stmt->bind_param($types, $ticketId, ...$visibility['params']);
if (!$stmt->execute()) {
throw new Exception('Execute failed: ' . $stmt->error);
}
@@ -54,22 +96,32 @@ class DependencyModel
/**
* Get tickets that depend on this ticket
*
* The linked ticket's title/status/priority are only returned for tickets the
* requesting user is allowed to see (same rules as TicketModel::getVisibilityFilter).
* With the default (null user, non-admin) only public tickets are exposed.
*
* @param string $ticketId Ticket ID
* @param int|null $userId Requesting user's ID (null = anonymous)
* @param array $userGroups Requesting user's group names
* @param bool $isAdmin Whether the requesting user is an admin (bypasses filtering)
* @return array Dependent tickets
*/
public function getDependentTickets($ticketId)
public function getDependentTickets($ticketId, $userId = null, array $userGroups = [], $isAdmin = false)
{
$visibility = $this->buildVisibilityClause($userId, $userGroups, $isAdmin);
$sql = "SELECT d.*, t.title, t.status, t.priority
FROM ticket_dependencies d
LEFT JOIN tickets t ON d.ticket_id = t.ticket_id
WHERE d.depends_on_id = ?
WHERE d.depends_on_id = ?" . $visibility['sql'] . "
ORDER BY d.dependency_type, d.created_at DESC";
$stmt = $this->conn->prepare($sql);
if (!$stmt) {
throw new Exception('Prepare failed: ' . $this->conn->error);
}
$stmt->bind_param("s", $ticketId);
$types = 's' . $visibility['types'];
$stmt->bind_param($types, $ticketId, ...$visibility['params']);
if (!$stmt->execute()) {
throw new Exception('Execute failed: ' . $stmt->error);
}
@@ -120,6 +172,27 @@ class DependencyModel
}
$checkStmt->close();
// Also check the semantic inverse: "A blocks B" and "B blocked_by A"
// describe the same relationship, so adding one from either ticket's
// page must be rejected as a duplicate of the other. relates_to is
// its own inverse (symmetric); duplicates has no defined inverse type.
$inverseTypes = ['blocks' => 'blocked_by', 'blocked_by' => 'blocks', 'relates_to' => 'relates_to'];
if (isset($inverseTypes[$type])) {
$inverseType = $inverseTypes[$type];
$checkInverseSql = "SELECT dependency_id FROM ticket_dependencies
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
$checkInverseStmt = $this->conn->prepare($checkInverseSql);
$checkInverseStmt->bind_param("sss", $dependsOnId, $ticketId, $inverseType);
$checkInverseStmt->execute();
$inverseResult = $checkInverseStmt->get_result();
if ($inverseResult->num_rows > 0) {
$checkInverseStmt->close();
return ['success' => false, 'error' => 'This relationship already exists'];
}
$checkInverseStmt->close();
}
// Check for circular dependency
if ($this->wouldCreateCycle($ticketId, $dependsOnId, $type)) {
return ['success' => false, 'error' => 'This would create a circular dependency'];
@@ -190,14 +263,25 @@ class DependencyModel
*/
private function wouldCreateCycle($ticketId, $dependsOnId, $type): bool
{
// Only check for cycles in blocking relationships
// Only blocking relationships impose an ordering that can form a cycle.
if (!in_array($type, ['blocks', 'blocked_by'])) {
return false;
}
// Check if dependsOnId already has ticketId in its dependency chain
// Normalize the new row to a precedence edge "from must finish before to":
// (t, d, 'blocks') => t blocks d => edge t -> d
// (t, d, 'blocked_by') => t blocked_by d => edge d -> t
if ($type === 'blocks') {
$from = $ticketId;
$to = $dependsOnId;
} else { // blocked_by
$from = $dependsOnId;
$to = $ticketId;
}
// Adding edge from->to creates a cycle iff a path to ->* from already exists.
$visited = [];
return $this->hasDependencyPath($dependsOnId, $ticketId, $visited, 0);
return $this->hasDependencyPath($to, $from, $visited, 0);
}
/**
@@ -236,15 +320,22 @@ class DependencyModel
$visited[] = $source;
$sql = "SELECT depends_on_id FROM ticket_dependencies
WHERE ticket_id = ? AND dependency_type IN ('blocks', 'blocked_by')";
// Walk the unified precedence graph forward from $source. Both directions
// of expression contribute an outgoing edge "$source must finish before X":
// blocks rows where ticket_id=$source -> X = depends_on_id
// blocked_by rows where depends_on_id=$source -> X = ticket_id
$sql = "SELECT depends_on_id AS next_id FROM ticket_dependencies
WHERE ticket_id = ? AND dependency_type = 'blocks'
UNION
SELECT ticket_id AS next_id FROM ticket_dependencies
WHERE depends_on_id = ? AND dependency_type = 'blocked_by'";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $source);
$stmt->bind_param("ss", $source, $source);
$stmt->execute();
$result = $stmt->get_result();
while ($row = $result->fetch_assoc()) {
if ($this->hasDependencyPath($row['depends_on_id'], $target, $visited, $depth + 1)) {
if ($this->hasDependencyPath($row['next_id'], $target, $visited, $depth + 1)) {
$stmt->close();
return true;
}
+28 -28
View File
@@ -65,7 +65,7 @@ class RecurringTicketModel
$stmt = $this->conn->prepare($sql);
$stmt->bind_param(
'ssssiiisssii',
'ssssiissssii',
$data['title_template'],
$data['description_template'],
$data['category'],
@@ -189,30 +189,6 @@ class RecurringTicketModel
return $claimed;
}
/**
* Update last run and calculate next run time
*/
public function updateAfterRun($recurringId)
{
$recurring = $this->getById($recurringId);
if (!$recurring) {
return false;
}
$nextRun = $this->calculateNextRunTime(
$recurring['schedule_type'],
$recurring['schedule_day'],
$recurring['schedule_time']
);
$sql = "UPDATE recurring_tickets SET last_run_at = NOW(), next_run_at = ? WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('si', $nextRun, $recurringId);
$success = $stmt->execute();
$stmt->close();
return $success;
}
/**
* Calculate the next run time based on schedule
*/
@@ -255,9 +231,33 @@ class RecurringTicketModel
*/
public function toggleActive($recurringId)
{
$sql = "UPDATE recurring_tickets SET is_active = NOT is_active WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $recurringId);
$recurring = $this->getById($recurringId);
if (!$recurring) {
return ['success' => false];
}
$newActive = $recurring['is_active'] ? 0 : 1;
if ($newActive) {
// Re-enabling: recompute next_run_at from now, as if the schedule
// were freshly created. Otherwise a schedule paused while
// next_run_at was still in the future, then re-enabled after that
// date has passed, would fire immediately on the next cron tick
// instead of waiting for its next natural occurrence.
$nextRun = $this->calculateNextRunTime(
$recurring['schedule_type'],
$recurring['schedule_day'],
$recurring['schedule_time']
);
$sql = "UPDATE recurring_tickets SET is_active = ?, next_run_at = ? WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('isi', $newActive, $nextRun, $recurringId);
} else {
$sql = "UPDATE recurring_tickets SET is_active = ? WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('ii', $newActive, $recurringId);
}
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
+23 -8
View File
@@ -28,8 +28,14 @@ class StatsModel
/**
* Get tickets by assignee (top 5)
*/
public function getTicketsByAssignee(int $limit = 8): array
public function getTicketsByAssignee(int $limit = 8, array $visFilter = []): array
{
// Apply the same visibility filter as the rest of the stats so a non-admin's
// assignee widget doesn't count (and thereby leak) confidential tickets.
$visSQL = $visFilter['sql'] ?? '';
$visParams = $visFilter['params'] ?? [];
$visTypes = $visFilter['types'] ?? '';
$sql = "SELECT
u.user_id,
u.display_name,
@@ -37,12 +43,20 @@ class StatsModel
COUNT(t.ticket_id) as open_count
FROM tickets t
LEFT JOIN users u ON t.assigned_to = u.user_id
WHERE t.status != 'Closed' AND t.assigned_to IS NOT NULL
GROUP BY t.assigned_to
ORDER BY open_count DESC
LIMIT ?";
WHERE t.status != 'Closed' AND t.assigned_to IS NOT NULL";
if ($visSQL !== '') {
$sql .= " AND ($visSQL)";
}
$sql .= " GROUP BY t.assigned_to
ORDER BY open_count DESC
LIMIT ?";
$params = $visParams;
$params[] = $limit;
$types = $visTypes . 'i';
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $limit);
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
$data = [];
@@ -173,8 +187,9 @@ class StatsModel
// Sort priority keys
ksort($byPriority);
// Query 3: Get assignee stats (requires JOIN, kept separate)
$byAssignee = $this->getTicketsByAssignee();
// Query 3: Get assignee stats (requires JOIN, kept separate). Pass the same
// visibility filter so confidential tickets aren't counted for non-admins.
$byAssignee = $this->getTicketsByAssignee(8, $visFilter);
return [
'open_tickets' => (int)($counts['open_tickets'] ?? 0),
+46 -31
View File
@@ -9,7 +9,7 @@ class TicketModel
$this->conn = $conn;
}
public function getTicketById(int $id): ?array
public function getTicketById(string $id): ?array
{
$sql = "SELECT t.*,
u_created.username as creator_username,
@@ -24,7 +24,7 @@ class TicketModel
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
WHERE t.ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $id);
$stmt->bind_param("s", $id);
$stmt->execute();
$result = $stmt->get_result();
@@ -82,18 +82,22 @@ class TicketModel
$paramTypes .= str_repeat('s', count($types));
}
// Search Functionality — use FULLTEXT when available, fall back to LIKE
if ($search && !empty($search)) {
if ($this->hasFulltextIndex()) {
// Search Functionality — use FULLTEXT when available, fall back to LIKE.
// Use a strict emptiness check so a literal "0" search is honored.
if ($search !== null && $search !== '') {
// Strip MySQL boolean mode special chars to prevent parse errors on user input
$ftSearch = trim(preg_replace('/\s+/', ' ', preg_replace('/[+\-><()\~*"@]+/', ' ', $search)));
if ($this->hasFulltextIndex() && $ftSearch !== '') {
// MATCH...AGAINST for indexed full-text search (much faster at scale)
// Strip MySQL boolean mode special chars to prevent parse errors on user input
$ftSearch = preg_replace('/[+\-><()\~*"@]+/', ' ', $search);
$ftSearch = trim(preg_replace('/\s+/', ' ', $ftSearch)) . '*';
$ftSearch .= '*';
$whereConditions[] = "(MATCH(t.title, t.description) AGAINST (? IN BOOLEAN MODE) OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
$searchTerm = "%$search%";
$params = array_merge($params, [$ftSearch, $searchTerm, $searchTerm, $searchTerm]);
$paramTypes .= 'ssss';
} else {
// No FULLTEXT index, or the sanitized boolean query is empty (search was
// only special chars) — fall back to LIKE instead of emitting invalid
// AGAINST('*' ...) syntax.
$whereConditions[] = "(t.title LIKE ? OR t.description LIKE ? OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
$searchTerm = "%$search%";
$params = array_merge($params, [$searchTerm, $searchTerm, $searchTerm, $searchTerm, $searchTerm]);
@@ -308,7 +312,7 @@ class TicketModel
if ($expectedUpdatedAt !== null) {
$stmt->bind_param(
"sissssisis",
"sissssisss",
$ticketData['title'],
$ticketData['priority'],
$ticketData['status'],
@@ -322,7 +326,7 @@ class TicketModel
);
} else {
$stmt->bind_param(
"sissssisi",
"sissssiss",
$ticketData['title'],
$ticketData['priority'],
$ticketData['status'],
@@ -343,20 +347,31 @@ class TicketModel
return ['success' => false, 'error' => 'Database error: ' . $this->conn->error, 'conflict' => false];
}
// Check for optimistic locking conflict
if ($expectedUpdatedAt !== null && $affectedRows === 0) {
// Either ticket doesn't exist or was modified by someone else
// Zero affected rows is ambiguous: the ticket may not exist, an optimistic
// lock may have failed, or the row simply matched with no column changes
// (identical resubmit). Disambiguate so we neither report a false conflict
// nor silently "succeed" on a non-existent ticket.
if ($affectedRows === 0) {
$ticket = $this->getTicketById($ticketData['ticket_id']);
if ($ticket) {
return [
'success' => false,
'error' => 'This ticket was modified by another user. Please refresh and try again.',
'conflict' => true,
'current_updated_at' => $ticket['updated_at']
];
} else {
if (!$ticket) {
return ['success' => false, 'error' => 'Ticket not found', 'conflict' => false];
}
if ($expectedUpdatedAt !== null) {
// Only a genuine concurrent modification changes updated_at. If it
// still equals the expected value the WHERE matched but nothing
// changed (e.g. identical data resubmitted within the same second),
// which is not a conflict.
if ($ticket['updated_at'] !== $expectedUpdatedAt) {
return [
'success' => false,
'error' => 'This ticket was modified by another user. Please refresh and try again.',
'conflict' => true,
'current_updated_at' => $ticket['updated_at']
];
}
}
// Ticket exists and no conflict: treat no-op update as success.
}
return ['success' => true, 'error' => null, 'conflict' => false];
@@ -516,9 +531,9 @@ class TicketModel
}
}
public function addComment(int $ticketId, array $commentData): array
public function addComment(string $ticketId, array $commentData): array
{
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
VALUES (?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
@@ -528,7 +543,7 @@ class TicketModel
$markdownEnabled = $commentData['markdown_enabled'] ? 1 : 0;
$stmt->bind_param(
"issi",
"sssi",
$ticketId,
$username,
$commentData['comment_text'],
@@ -557,11 +572,11 @@ class TicketModel
* @param int $assignedBy User ID performing the assignment
* @return bool Success status
*/
public function assignTicket(int $ticketId, int $userId, int $assignedBy): bool
public function assignTicket(string $ticketId, int $userId, int $assignedBy): bool
{
$sql = "UPDATE tickets SET assigned_to = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("iii", $userId, $assignedBy, $ticketId);
$stmt->bind_param("iis", $userId, $assignedBy, $ticketId);
$result = $stmt->execute();
$stmt->close();
return $result;
@@ -574,11 +589,11 @@ class TicketModel
* @param int $updatedBy User ID performing the unassignment
* @return bool Success status
*/
public function unassignTicket(int $ticketId, int $updatedBy): bool
public function unassignTicket(string $ticketId, int $updatedBy): bool
{
$sql = "UPDATE tickets SET assigned_to = NULL, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ii", $updatedBy, $ticketId);
$stmt->bind_param("is", $updatedBy, $ticketId);
$result = $stmt->execute();
$stmt->close();
return $result;
@@ -733,7 +748,7 @@ class TicketModel
* @param int $updatedBy User ID
* @return bool
*/
public function updateVisibility(int $ticketId, string $visibility, ?string $visibilityGroups, int $updatedBy): bool
public function updateVisibility(string $ticketId, string $visibility, ?string $visibilityGroups, int $updatedBy): bool
{
$allowedVisibilities = ['public', 'internal', 'confidential'];
if (!in_array($visibility, $allowedVisibilities)) {
@@ -752,7 +767,7 @@ class TicketModel
$sql = "UPDATE tickets SET visibility = ?, visibility_groups = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ssii", $visibility, $visibilityGroups, $updatedBy, $ticketId);
$stmt->bind_param("ssis", $visibility, $visibilityGroups, $updatedBy, $ticketId);
$result = $stmt->execute();
$stmt->close();
return $result;
@@ -790,7 +805,7 @@ class TicketModel
"DELETE FROM ticket_watchers WHERE ticket_id = ?",
"DELETE FROM ticket_dependencies WHERE ticket_id = ? OR depends_on_id = ?",
"DELETE FROM ticket_attachments WHERE ticket_id = ?",
"DELETE FROM ticket_custom_fields WHERE ticket_id = ?",
"DELETE FROM custom_field_values WHERE ticket_id = ?",
];
foreach ($children as $sql) {
+18 -7
View File
@@ -98,19 +98,30 @@ class UserModel
$user['groups'] = $groups;
$user['is_admin'] = $isAdmin;
} else {
// Create new user
// Create new user. Uses INSERT ... ON DUPLICATE KEY UPDATE (rather than
// a plain INSERT) so two concurrent first-visit requests for the same
// brand-new username can't race: the losing request updates the row the
// winner just created instead of throwing an uncaught duplicate-key
// exception (users.username has a UNIQUE KEY, and mysqli throws on
// constraint violation under PHP 8.1+'s default report mode).
$insertStmt = $this->conn->prepare(
"INSERT INTO users (username, display_name, email, `groups`, is_admin, last_login) VALUES (?, ?, ?, ?, ?, NOW())"
"INSERT INTO users (username, display_name, email, `groups`, is_admin, last_login)
VALUES (?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name),
email = VALUES(email),
`groups` = VALUES(groups),
is_admin = VALUES(is_admin),
last_login = NOW()"
);
$insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin);
$insertStmt->execute();
$userId = $this->conn->insert_id;
$insertStmt->close();
// Get the newly created user
$stmt = $this->conn->prepare("SELECT * FROM users WHERE user_id = ?");
$stmt->bind_param("i", $userId);
// Re-fetch by username — works whether this request won the insert or
// lost the race and only updated the winner's row.
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = ?");
$stmt->bind_param("s", $username);
$stmt->execute();
$result = $stmt->get_result();
$user = $result->fetch_assoc();
+66 -37
View File
@@ -26,31 +26,38 @@ class WorkflowModel
*/
private function getAllTransitions(): array
{
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_transitions', function () {
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
FROM status_transitions
WHERE is_active = TRUE";
$result = $this->conn->query($sql);
$cached = CacheHelper::get(self::$CACHE_PREFIX, 'all_transitions', self::$CACHE_TTL);
if ($cached !== null) {
return $cached;
}
if (!$result) {
return [];
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
FROM status_transitions
WHERE is_active = TRUE";
$result = $this->conn->query($sql);
if (!$result) {
// A transient DB failure must NOT be cached as "no transitions" — that
// would block every status change for the whole TTL. Fail safe by
// returning empty without storing it, so the next call retries.
return [];
}
$transitions = [];
while ($row = $result->fetch_assoc()) {
$from = $row['from_status'];
if (!isset($transitions[$from])) {
$transitions[$from] = [];
}
$transitions[$from][$row['to_status']] = [
'to_status' => $row['to_status'],
'requires_comment' => (bool)$row['requires_comment'],
'requires_admin' => (bool)$row['requires_admin']
];
}
$transitions = [];
while ($row = $result->fetch_assoc()) {
$from = $row['from_status'];
if (!isset($transitions[$from])) {
$transitions[$from] = [];
}
$transitions[$from][$row['to_status']] = [
'to_status' => $row['to_status'],
'requires_comment' => (bool)$row['requires_comment'],
'requires_admin' => (bool)$row['requires_admin']
];
}
return $transitions;
}, self::$CACHE_TTL);
CacheHelper::set(self::$CACHE_PREFIX, 'all_transitions', $transitions);
return $transitions;
}
/**
@@ -107,24 +114,29 @@ class WorkflowModel
*/
public function getAllStatuses(): array
{
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_statuses', function () {
$sql = "SELECT DISTINCT from_status as status FROM status_transitions
UNION
SELECT DISTINCT to_status as status FROM status_transitions
ORDER BY status";
$result = $this->conn->query($sql);
$cached = CacheHelper::get(self::$CACHE_PREFIX, 'all_statuses', self::$CACHE_TTL);
if ($cached !== null) {
return $cached;
}
if (!$result) {
return [];
}
$sql = "SELECT DISTINCT from_status as status FROM status_transitions
UNION
SELECT DISTINCT to_status as status FROM status_transitions
ORDER BY status";
$result = $this->conn->query($sql);
$statuses = [];
while ($row = $result->fetch_assoc()) {
$statuses[] = $row['status'];
}
if (!$result) {
// Do not cache an empty list on a transient DB failure.
return [];
}
return $statuses;
}, self::$CACHE_TTL);
$statuses = [];
while ($row = $result->fetch_assoc()) {
$statuses[] = $row['status'];
}
CacheHelper::set(self::$CACHE_PREFIX, 'all_statuses', $statuses);
return $statuses;
}
/**
@@ -149,6 +161,23 @@ class WorkflowModel
];
}
/**
* Whether a given transition requires a comment.
*
* Convenience accessor so callers (e.g. the update-ticket endpoint) can
* enforce requires_comment server-side without inspecting the full row.
* Returns false for an undefined transition or a no-op (same status).
*
* @param string $fromStatus Current status
* @param string $toStatus Desired status
* @return bool True if the transition requires a comment
*/
public function transitionRequiresComment(string $fromStatus, string $toStatus): bool
{
$requirements = $this->getTransitionRequirements($fromStatus, $toStatus);
return $requirements !== null && !empty($requirements['requires_comment']);
}
/**
* Clear workflow cache (call when transitions are modified)
*/
+47
View File
@@ -10,9 +10,30 @@
* Usage: php scripts/check_requirements.php
*/
/**
* Parse a php.ini size value (e.g. "128M", "1G", "-1") into bytes.
* Returns -1 for unlimited.
*/
function parseIniBytes(string $val): int
{
$val = trim($val);
if ($val === '' || $val === '-1') {
return -1;
}
$unit = strtolower(substr($val, -1));
$num = (int)$val;
return match ($unit) {
'g' => $num * 1024 * 1024 * 1024,
'm' => $num * 1024 * 1024,
'k' => $num * 1024,
default => $num,
};
}
$req = require __DIR__ . '/../config/requirements.php';
$errors = [];
$warnings = [];
// PHP version
$minPhp = $req['min_php_version'];
@@ -27,6 +48,28 @@ foreach ($req['required_extensions'] as $ext) {
}
}
// memory_limit / max_execution_time sanity checks (warnings, not hard
// failures — see config/requirements.php for why these matter).
$memLimitIni = ini_get('memory_limit');
$memLimitBytes = parseIniBytes($memLimitIni);
$minMemBytes = $req['min_memory_limit_mb'] * 1024 * 1024;
if ($memLimitBytes !== -1 && $memLimitBytes < $minMemBytes) {
$warnings[] = sprintf(
'memory_limit is %s, below the recommended minimum %dM',
$memLimitIni,
$req['min_memory_limit_mb']
);
}
$maxExecTime = (int)ini_get('max_execution_time');
if ($maxExecTime !== 0 && $maxExecTime < $req['min_max_execution_time']) {
$warnings[] = sprintf(
'max_execution_time is %ds, below the recommended minimum %ds',
$maxExecTime,
$req['min_max_execution_time']
);
}
if (!empty($errors)) {
fwrite(STDERR, "Requirement check FAILED:\n");
foreach ($errors as $err) {
@@ -35,6 +78,10 @@ if (!empty($errors)) {
exit(1);
}
foreach ($warnings as $warn) {
fwrite(STDERR, "Requirement check WARNING: " . $warn . "\n");
}
printf(
"Requirement check passed: PHP %s (>= %s); extensions: %s\n",
PHP_VERSION,
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env php
<?php
/**
* Orphan Upload Cleanup
*
* Removes files under uploads/<ticketId>/ that have NO matching row in
* ticket_attachments (e.g. leftovers from a failed DB insert). Intended to be
* run from cron:
* 0 4 * * * /usr/bin/php /path/to/scripts/cleanup_orphan_uploads.php >> /var/log/orphan_uploads.log 2>&1
*
* SAFETY:
* - Only files older than a grace period (GRACE_SECONDS, default 24h) are
* considered, so a freshly written file whose DB row has not been inserted
* yet (in-flight upload) is never deleted.
* - Only 9-digit ticket directories are scanned. uploads/avatars/ (and any
* other non-ticket directory) is skipped entirely.
* - A file is deleted only when no ticket_attachments row references its
* stored filename (looked up with a prepared statement).
*
* Usage:
* php cleanup_orphan_uploads.php # delete orphaned files past grace period
* php cleanup_orphan_uploads.php --dry-run # report only, delete nothing
*/
// Prevent web access
if (php_sapi_name() !== 'cli') {
http_response_code(403);
exit('CLI access only');
}
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
/** Files younger than this (seconds) are never touched — protects in-flight uploads. */
const GRACE_SECONDS = 86400;
$dryRun = in_array('--dry-run', $argv, true);
function logMessage($message)
{
echo '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n";
}
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? (dirname(__DIR__) . '/uploads');
$uploadRoot = realpath($uploadDir);
if ($uploadRoot === false || !is_dir($uploadRoot)) {
logMessage("Upload directory not found: {$uploadDir}");
exit(0);
}
logMessage('Starting orphan upload cleanup' . ($dryRun ? ' (DRY RUN)' : ''));
try {
$conn = Database::getConnection();
} catch (Exception $e) {
logMessage('FATAL ERROR: could not connect to database: ' . $e->getMessage());
exit(1);
}
// Prepared lookup: does any attachment row reference this stored filename?
// Stored filenames are globally unique (uniqid), so filename alone is sufficient
// and safe — a match in any ticket means the file is a real attachment.
$lookup = $conn->prepare('SELECT 1 FROM ticket_attachments WHERE filename = ? LIMIT 1');
if ($lookup === false) {
logMessage('FATAL ERROR: could not prepare lookup statement: ' . $conn->error);
exit(1);
}
$now = time();
$scanned = 0;
$orphaned = 0;
$deleted = 0;
$skippedTooNew = 0;
$errors = 0;
foreach (new DirectoryIterator($uploadRoot) as $entry) {
if ($entry->isDot() || !$entry->isDir() || $entry->isLink()) {
continue;
}
// Ticket directories are 9-digit ticket IDs. Skip avatars/ and anything else.
$dirName = $entry->getFilename();
if (!preg_match('/^\d{9}$/', $dirName)) {
continue;
}
foreach (new DirectoryIterator($entry->getPathname()) as $file) {
if ($file->isDot() || !$file->isFile() || $file->isLink()) {
continue;
}
$scanned++;
$filename = $file->getFilename();
// Never touch files younger than the grace period (in-flight uploads).
$age = $now - $file->getMTime();
if ($age < GRACE_SECONDS) {
$skippedTooNew++;
continue;
}
// Keep the file if any attachment row references it.
$lookup->bind_param('s', $filename);
$lookup->execute();
$hasRow = $lookup->get_result()->num_rows > 0;
if ($hasRow) {
continue;
}
$orphaned++;
$path = $file->getPathname();
if ($dryRun) {
logMessage("WOULD DELETE orphan: {$dirName}/{$filename}");
continue;
}
if (@unlink($path)) {
$deleted++;
logMessage("Deleted orphan: {$dirName}/{$filename}");
} else {
$errors++;
logMessage("ERROR: could not delete: {$dirName}/{$filename}");
}
}
}
$lookup->close();
Database::close();
logMessage('Cleanup complete' . ($dryRun ? ' (DRY RUN — nothing deleted)' : '') . ':');
logMessage(" - Scanned: {$scanned} files");
logMessage(" - Orphaned: {$orphaned} files");
logMessage(" - Deleted: {$deleted} files");
logMessage(" - Skipped (too new): {$skippedTooNew} files");
if ($errors > 0) {
logMessage(" - Errors: {$errors} files");
}
exit($errors > 0 ? 1 : 0);
+15 -4
View File
@@ -344,12 +344,23 @@ include __DIR__ . '/layout_header.php';
var existingTitle = (document.getElementById('title').value || '').trim();
var existingDesc = (document.getElementById('description').value || '').trim();
if (existingTitle || existingDesc) {
if (!confirm('Applying this template will overwrite your current title and description. Continue?')) {
document.getElementById('templateSelect').value = '';
return;
}
showConfirmModal(
'Overwrite content?',
'Applying this template will overwrite your current title and description. Continue?',
'warning',
applyTemplate,
function () { document.getElementById('templateSelect').value = ''; }
);
return;
}
applyTemplate();
}
function applyTemplate() {
var tplId = document.getElementById('templateSelect').value;
if (!tplId) return;
lt.api.get('/api/get_template.php?template_id=' + encodeURIComponent(tplId))
.then(function (data) {
if (!data.success || !data.template) {
+83 -10
View File
@@ -120,7 +120,6 @@ include __DIR__ . '/layout_header.php';
?>
<div class="lt-stat-card stat-open" role="button" tabindex="0"
data-filter-key="status" data-filter-val="Open,Pending,In Progress"
title="Click to filter by active tickets" aria-label="Open tickets">
<div class="lt-stat-icon">[ # ]</div>
<div class="lt-stat-info">
@@ -133,7 +132,6 @@ include __DIR__ . '/layout_header.php';
</div>
<div class="lt-stat-card stat-critical" role="button" tabindex="0"
data-filter-key="priority" data-filter-val="1"
title="Click to filter critical (P1) tickets" aria-label="Critical P1 tickets">
<div class="lt-stat-icon lt-text-danger">[ ! ]</div>
<div class="lt-stat-info">
@@ -146,7 +144,6 @@ include __DIR__ . '/layout_header.php';
</div>
<div class="lt-stat-card stat-unassigned" role="button" tabindex="0"
data-filter-key="assigned_to" data-filter-val="unassigned"
title="Click to filter unassigned tickets" aria-label="Unassigned tickets">
<div class="lt-stat-icon lt-text-amber">[ @ ]</div>
<div class="lt-stat-info">
@@ -171,7 +168,6 @@ include __DIR__ . '/layout_header.php';
</div>
<div class="lt-stat-card stat-resolved" role="button" tabindex="0"
data-filter-key="status" data-filter-val="Closed"
title="Click to filter closed tickets" aria-label="Closed tickets today">
<div class="lt-stat-icon lt-text-muted">[ OK ]</div>
<div class="lt-stat-info">
@@ -277,9 +273,66 @@ include __DIR__ . '/layout_header.php';
array_values($stats['by_category'] ?? [])
))) ?>;
// ── Click-to-filter ────────────────────────────────────────────────────────
// Charts navigate to the same URL filters the stat cards use.
//
// The status the click filters on has to be explicit rather than left to the
// default: with no `status` param the controller falls back to the viewer's
// default_status_filters preference, which can be anything, so the resulting
// list would not necessarily match what the chart counted. StatsModel builds
// by_priority and by_category with `status != 'Closed'`, while by_status spans
// every status — so only the priority and category charts pin the open set.
function openStatuses() {
var all = window.TICKET_STATUSES || ['Open', 'Pending', 'In Progress', 'Closed'];
return all.filter(function(s) { return s !== 'Closed'; }).join(',');
}
function gotoFilter(params) {
var qs = new URLSearchParams(window.location.search);
Object.keys(params).forEach(function(k) {
if (params[k] !== null && params[k] !== undefined && params[k] !== '') qs.set(k, params[k]);
else qs.delete(k);
});
window.location.href = '/?' + qs.toString();
}
// Each chart maps a clicked label to a filter. Returns null when the label
// can't be mapped, so the click is simply ignored.
var CHART_FILTERS = {
chartPriority: function(label) {
var m = /^P(\d+)$/.exec(label);
return m ? { priority: m[1], status: openStatuses() } : null;
},
chartStatus: function(label) {
return label ? { status: label } : null;
},
chartCategory: function(label) {
return label ? { category: label, status: openStatuses() } : null;
}
};
function filterOnClick(canvasId) {
return function(evt, elements, chart) {
if (!elements || !elements.length) return;
var label = chart.data.labels[elements[0].index];
var mapper = CHART_FILTERS[canvasId];
var params = mapper && mapper(label);
if (params) gotoFilter(params);
};
}
// Pointer cursor over clickable segments so the affordance is visible.
function filterOnHover(evt, elements) {
if (evt && evt.native && evt.native.target) {
evt.native.target.style.cursor = (elements && elements.length) ? 'pointer' : 'default';
}
}
function makeDonut(canvasId, data, colorMap) {
var ctx = document.getElementById(canvasId);
if (!ctx || !data.length) return;
if (!ctx) return;
if (!data.length) { showChartEmptyState(ctx); return; }
ctx.title = 'Click a segment to filter the ticket list';
return new Chart(ctx, {
type: 'doughnut',
data: {
@@ -295,21 +348,37 @@ include __DIR__ . '/layout_header.php';
},
options: {
responsive: true, maintainAspectRatio: false,
onClick: filterOnClick(canvasId),
onHover: filterOnHover,
plugins: {
legend: {
position: 'bottom',
labels: { color: '#8fa3b1', font: { family: 'monospace', size: 10 }, padding: 8, boxWidth: 10 }
},
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.label + ': ' + ctx.parsed; } } }
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.label + ': ' + ctx.parsed + ' — click to filter'; } } }
},
cutout: '68%'
}
});
}
function showChartEmptyState(canvas) {
canvas.style.display = 'none';
var wrap = canvas.parentElement;
if (wrap && !wrap.querySelector('.lt-chart-empty')) {
var msg = document.createElement('div');
msg.className = 'lt-chart-empty';
msg.style.cssText = 'display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:0.75rem';
msg.textContent = 'No data for current filters';
wrap.appendChild(msg);
}
}
function makeBar(canvasId, data) {
var ctx = document.getElementById(canvasId);
if (!ctx || !data.length) return;
if (!ctx) return;
if (!data.length) { showChartEmptyState(ctx); return; }
ctx.title = 'Click a bar to filter the ticket list';
return new Chart(ctx, {
type: 'bar',
data: {
@@ -323,7 +392,12 @@ include __DIR__ . '/layout_header.php';
},
options: {
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
onClick: filterOnClick(canvasId),
onHover: filterOnHover,
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.parsed.x + ' — click to filter'; } } }
},
scales: {
x: { ticks: { color: '#8fa3b1', font: { size: 10 } }, grid: { color: 'rgba(0,255,65,0.06)' } },
y: { ticks: { color: '#8fa3b1', font: { family: 'monospace', size: 10 } }, grid: { display: false } }
@@ -1162,7 +1236,6 @@ window.TICKET_STATUSES = <?= json_encode($GLOBALS['config']['TICKET_STATUSES'])
if (window.lt) {
lt.keys.initDefaults();
lt.tableNav.init('tickets-table');
lt.statsFilter.init();
}
// Saved filter pills — load on page init
@@ -1317,7 +1390,7 @@ if (advForm) advForm.addEventListener('submit', function(e) {
var pLabels = { '1':'P1 — Critical', '2':'P2 — High', '3':'P3 — Medium', '4':'P4 — Low', '5':'P5 — Minimal' };
var dotClass = { 'Open':'lt-dot-up', 'In Progress':'lt-dot-warn', 'Pending':'lt-dot--orange', 'Closed':'lt-dot-idle' };
function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
function fmtAge(dateStr) {
var d = new Date(dateStr);
+13 -16
View File
@@ -461,8 +461,8 @@ include __DIR__ . '/layout_header.php';
<button type="button" class="lt-tab" id="comments-tab-btn"
role="tab" data-tab="comments-panel" aria-selected="false" aria-controls="comments-panel">
Comments
<?php if (!empty($comments)) : ?>
<span class="lt-badge lt-badge-sm"><?= count($comments) ?></span>
<?php if ($totalComments > 0) : ?>
<span class="lt-badge lt-badge-sm"><?= (int)$totalComments ?></span>
<?php endif ?>
</button>
<button type="button" class="lt-tab" id="attachments-tab-btn"
@@ -621,11 +621,14 @@ include __DIR__ . '/layout_header.php';
</div>
</div>
<div class="comment-text<?= $markdownEnabled ? ' lt-markdown' : '' ?>" id="comment-text-<?= $commentId ?>"
<?= $markdownEnabled ? 'data-markdown' : '' ?>>
<?= $markdownEnabled
<?= $markdownEnabled ? 'data-markdown' : '' ?>><?=
// Emit inline (no surrounding whitespace) so a markdown
// comment's text content isn't prefixed with template
// indentation, which would be parsed as a code block.
$markdownEnabled
? htmlspecialchars($comment['comment_text'])
: nl2br(htmlspecialchars($comment['comment_text'])) ?>
</div>
: nl2br(htmlspecialchars($comment['comment_text']))
?></div>
<textarea class="lt-input lt-textarea comment-edit-raw is-hidden"
id="comment-raw-<?= $commentId ?>"
aria-hidden="true"><?= htmlspecialchars($comment['comment_text']) ?></textarea>
@@ -1003,9 +1006,7 @@ document.addEventListener('DOMContentLoaded', function () {
shown.forEach(function (w) {
var words = (w.display_name || '').trim().split(/\s+/).filter(Boolean);
var initials = words.slice(0, 2).map(function (x) { return x[0].toUpperCase(); }).join('');
var hash = 0;
for (var i = 0; i < (w.display_name || '').length; i++) hash = ((hash << 5) - hash + (w.display_name || '').charCodeAt(i)) | 0;
var color = avatarColors[Math.abs(hash) % 4];
var color = avatarColors[crc32(w.display_name || '') % 4];
html += '<div class="lt-avatar lt-avatar--xs ' + color + '" title="' + lt.escHtml(w.display_name) + '" aria-label="' + lt.escHtml(w.display_name) + '">' +
'<img src="/api/user_avatar.php?user_id=' + w.user_id + '" alt="" class="lt-avatar-img">' +
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
@@ -1216,7 +1217,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (typeof parseMarkdown === 'function') {
list.querySelectorAll('.comment-text[data-markdown]').forEach(function (el) {
if (!el.dataset.rendered) {
el.innerHTML = parseMarkdown(el.textContent);
el.innerHTML = parseMarkdown(el.textContent.trim());
el.dataset.rendered = '1';
}
});
@@ -1249,13 +1250,9 @@ document.addEventListener('DOMContentLoaded', function () {
var words = displayName.trim().split(/\s+/).filter(Boolean);
var initials = words.slice(0, 2).map(function (w) { return w[0].toUpperCase(); }).join('');
// Avatar color (same modulo logic as PHP: crc32 mod 4)
// Avatar color (real crc32, matching PHP's crc32 % 4 exactly)
var avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
var hash = 0;
for (var i = 0; i < displayName.length; i++) {
hash = ((hash << 5) - hash + displayName.charCodeAt(i)) | 0;
}
var avatarColor = avatarColors[Math.abs(hash) % 4];
var avatarColor = avatarColors[crc32(displayName) % 4];
// Format date
var dateStr = c.created_at || '';
+94 -12
View File
@@ -38,8 +38,18 @@ include __DIR__ . '/../../views/layout_header.php';
<option value="365">1 year</option>
</select>
</div>
<div class="lt-form-group" style="flex:1;margin:0">
<label class="lt-label" for="keyScope">Scope</label>
<select id="keyScope" class="lt-select">
<option value="read_write" selected>read_write</option>
<option value="read">read</option>
</select>
</div>
<button type="submit" class="lt-btn lt-btn-primary" style="margin-bottom:0">GENERATE KEY</button>
</form>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">
Scope: <strong>read</strong> = GET only; <strong>read_write</strong> = create/comment/close.
</p>
<!-- New key display (hidden by default) -->
<div id="newKeyDisplay" class="lt-frame-inner lt-mt-sm is-hidden">
@@ -63,6 +73,7 @@ include __DIR__ . '/../../views/layout_header.php';
<tr>
<th scope="col">Name</th>
<th scope="col">Key Prefix</th>
<th scope="col">Scope</th>
<th scope="col">Created By</th>
<th scope="col">Created</th>
<th scope="col">Expires</th>
@@ -72,14 +83,26 @@ include __DIR__ . '/../../views/layout_header.php';
</tr>
</thead>
<tbody>
<?php if (empty($apiKeys)) : ?>
<tr><td colspan="8" class="lt-empty">No API keys found. Generate one above.</td></tr>
<?php else :
foreach ($apiKeys as $key) : ?>
<?php $expired = $key['expires_at'] && strtotime($key['expires_at']) < time(); ?>
<?php
$apiKeysList = $apiKeys['keys'] ?? [];
if (empty($apiKeysList)) : ?>
<tr><td colspan="9" class="lt-empty">No API keys found. Generate one above.</td></tr>
<?php else :
foreach ($apiKeysList as $key) : ?>
<?php
$expired = $key['expires_at'] && strtotime($key['expires_at']) < time();
$scope = $key['scope'] ?? 'read_write';
?>
<tr id="key-row-<?= (int)$key['api_key_id'] ?>">
<td data-label="Name"><strong><?= htmlspecialchars($key['key_name']) ?></strong></td>
<td data-label="Prefix" class="lt-text-xs"><code><?= htmlspecialchars($key['key_prefix']) ?>&hellip;</code></td>
<td data-label="Scope">
<?php if ($scope === 'read') : ?>
<span class="lt-status lt-status-closed"><?= htmlspecialchars($scope) ?></span>
<?php else : ?>
<span class="lt-status lt-status-open"><?= htmlspecialchars($scope) ?></span>
<?php endif ?>
</td>
<td data-label="Created By" class="lt-text-xs"><?= htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown') ?></td>
<td data-label="Created" class="lt-text-xs lt-text-muted"><?= date('Y-m-d H:i', strtotime($key['created_at'])) ?></td>
<td data-label="Expires" class="lt-text-xs <?= $expired ? 'lt-text-danger' : 'lt-text-cyan' ?>">
@@ -104,11 +127,30 @@ include __DIR__ . '/../../views/layout_header.php';
<?php endif ?>
</td>
</tr>
<?php endforeach;
endif ?>
<?php endforeach;
endif ?>
</tbody>
</table>
</div>
<!-- Pagination -->
<?php
$akPage = (int)($apiKeys['page'] ?? 1);
$akPerPage = max(1, (int)($apiKeys['perPage'] ?? 20));
$akTotal = (int)($apiKeys['total'] ?? 0);
$akPages = (int)ceil($akTotal / $akPerPage);
?>
<?php if ($akPages > 1) : ?>
<div class="lt-pagination" role="navigation" aria-label="API keys pagination">
<?php if ($akPage > 1) : ?>
<a href="/admin/api-keys?page=<?= $akPage - 1 ?>" class="lt-btn lt-btn-sm" aria-label="Previous page">&#xAB; Prev</a>
<?php endif ?>
<span class="lt-text-xs lt-text-muted">Page <?= $akPage ?> of <?= $akPages ?></span>
<?php if ($akPage < $akPages) : ?>
<a href="/admin/api-keys?page=<?= $akPage + 1 ?>" class="lt-btn lt-btn-sm" aria-label="Next page">Next &#xBB;</a>
<?php endif ?>
</div>
<?php endif ?>
</div>
</div>
@@ -127,17 +169,56 @@ include __DIR__ . '/../../views/layout_header.php';
</div>
<pre><code>Authorization: Bearer YOUR_API_KEY</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">
Example create a ticket via cURL:<br>
<?php $apiBase = 'https://' . htmlspecialchars($GLOBALS['config']['APP_DOMAIN'] ?? 'your-instance', ENT_QUOTES); ?>
<p class="lt-text-sm lt-text-muted" style="margin-top:0.75rem">
<strong>Scopes:</strong> a <code>read</code> key may only use the <code>GET</code> endpoints;
a <code>read_write</code> key may also create tickets, post comments, and change status.
All endpoints are Bearer-authenticated and rate-limited. Comments and status changes made via
the API are attributed to the key's name.
</p>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>Create a ticket</strong> (read_write):</p>
<div class="lt-code-block">
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
<pre><code>curl -X POST https://your-instance/api/create_ticket.php \
<pre><code>curl -X POST <?= $apiBase ?>/create_ticket_api.php \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"My ticket","category":"General","type":"Issue","priority":3}'</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">API keys provide programmatic access to create and manage tickets. Keep keys secure and rotate them regularly.</p>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>List / triage the queue</strong> (read). Filters: <code>status</code>, <code>priority</code> (1-5), <code>host</code> (title match), <code>page</code>, <code>limit</code>:</p>
<div class="lt-code-block">
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
<pre><code>curl "<?= $apiBase ?>/api/tickets_api.php?status=Open&priority=2&limit=25" \
-H "Authorization: Bearer YOUR_API_KEY"</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>Read one ticket + its comments</strong> (read):</p>
<div class="lt-code-block">
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
<pre><code>curl "<?= $apiBase ?>/api/tickets_api.php?ticket_id=123456789" \
-H "Authorization: Bearer YOUR_API_KEY"</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>Post a comment</strong> (read_write). <code>markdown_enabled</code> is optional:</p>
<div class="lt-code-block">
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
<pre><code>curl -X POST <?= $apiBase ?>/api/ticket_comment_api.php \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"ticket_id":"123456789","comment_text":"Investigating.","markdown_enabled":true}'</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>Change / close status</strong> (read_write, workflow-validated). <code>comment</code> is required for transitions that require one (e.g. closing) and is posted as the reason:</p>
<div class="lt-code-block">
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
<pre><code>curl -X POST <?= $apiBase ?>/api/ticket_status_api.php \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"ticket_id":"123456789","status":"Closed","comment":"Resolved: disk replaced."}'</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem">Keep keys secure and rotate them regularly. Scope automation keys to <code>read</code> unless they need to write.</p>
</div>
</div>
@@ -160,8 +241,9 @@ document.getElementById('generateKeyForm').addEventListener('submit', function (
e.preventDefault();
var keyName = document.getElementById('keyName').value.trim();
var expiresIn = document.getElementById('expiresIn').value;
var keyScope = document.getElementById('keyScope').value;
if (!keyName) { lt.toast.error('Please enter a key name'); return; }
lt.api.post('/api/generate_api_key.php', { key_name: keyName, expires_in_days: expiresIn || null })
lt.api.post('/api/generate_api_key.php', { key_name: keyName, expires_in_days: expiresIn || null, scope: keyScope })
.then(function (data) {
if (data.success) {
document.getElementById('newKeyValue').value = data.api_key;
+7 -2
View File
@@ -29,9 +29,14 @@ include __DIR__ . '/../../views/layout_header.php';
<label class="lt-label" for="action_type">Action Type</label>
<select name="action_type" id="action_type" class="lt-select lt-select-sm">
<option value="">All Actions</option>
<?php foreach (['create','update','delete','comment','assign','status_change','login','security'] as $a) : ?>
<?php
// Mirrors AuditLogModel::VALID_ACTION_TYPES (the backend whitelist of loggable actions)
$auditActionTypes = ['create','update','delete','view','security_event',
'login','logout','assign','unassign','comment','mention',
'revoke','attachment_upload','attachment_delete','bulk_update'];
foreach ($auditActionTypes as $a) : ?>
<option value="<?= htmlspecialchars($a, ENT_QUOTES, 'UTF-8') ?>" <?= ($filters['action_type'] ?? '') === $a ? 'selected' : '' ?>><?= htmlspecialchars(ucfirst(str_replace('_', ' ', $a)), ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach ?>
<?php endforeach ?>
</select>
</div>
<div class="lt-form-group" style="margin:0">
+13 -1
View File
@@ -43,11 +43,23 @@ include __DIR__ . '/../../views/layout_header.php';
<!-- Summary stats -->
<?php if (!empty($userStats)) : ?>
<?php
// "Active" = users with >=1 tracked action within the selected date range.
// The query LEFT JOINs from all users, so $userStats includes zero-activity users.
$activeUsers = 0;
foreach ($userStats as $_u) {
$_activity = ($_u['tickets_created'] ?? 0) + ($_u['tickets_resolved'] ?? 0)
+ ($_u['comments_added'] ?? 0) + ($_u['tickets_assigned'] ?? 0);
if ($_activity > 0) {
$activeUsers++;
}
}
?>
<div class="lt-stats-grid lt-mb-md">
<div class="lt-stat-card">
<div class="lt-stat-icon lt-text-cyan">[ # ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= count($userStats) ?></div>
<div class="lt-stat-value"><?= (int)$activeUsers ?></div>
<div class="lt-stat-label">Active Users</div>
</div>
</div>
+48 -6
View File
@@ -138,10 +138,13 @@
var themeBtn = document.getElementById('lt-theme-btn');
if (themeBtn) themeBtn.addEventListener('click', function() { lt.theme.toggle(); });
// Command palette — global navigation commands available on all pages
// Command palette — single global instance (overlay DOM above; base.js binds Ctrl/Cmd+K)
var _cpCmds = [
{ id: 'nav-dashboard', group: 'Navigation', icon: '~', label: 'Dashboard', kbd: 'G D', action: function() { window.location.href = '/'; } },
{ id: 'nav-new-ticket', group: 'Navigation', icon: '+', label: 'New Ticket', kbd: 'N', action: function() { window.location.href = '/ticket/create'; } },
{ id: 'filter-mine', group: 'Filter', icon: '◈', label: 'My Open Tickets', action: function() { window.location.href = '/?assigned_to=me&status=Open,In+Progress,Pending'; } },
{ id: 'filter-unassigned', group: 'Filter', icon: '◌', label: 'Unassigned Tickets', action: function() { window.location.href = '/?assigned_to=unassigned'; } },
{ id: 'filter-critical', group: 'Filter', icon: '!', label: 'P1 Critical Tickets', action: function() { window.location.href = '/?priority=1'; } },
{ id: 'help-shortcuts', group: 'Help', icon: '?', label: 'Keyboard Shortcuts', kbd: '?', action: function() { lt.modal.open('lt-keys-help'); } },
{ id: 'help-theme', group: 'Help', icon: '*', label: 'Toggle Theme', action: function() { lt.theme.toggle(); } },
];
@@ -156,7 +159,20 @@
{ id: 'admin-api-keys', group: 'Admin', icon: 'K', label: 'API Keys', action: function() { window.location.href = '/admin/api-keys'; } },
]);
<?php endif ?>
// Recently viewed tickets from localStorage
try {
var _recent = JSON.parse(localStorage.getItem('lt_recent_tickets') || '[]');
_recent.slice(0, 5).forEach(function(id) {
_cpCmds.push({ id: 'recent-' + id, group: 'Recent', icon: '◷', label: 'Ticket #' + id, tags: ['ticket'], action: function(tid) { return function() { window.location.href = '/ticket/' + tid; }; }(id) });
});
} catch (_e) { /* ignore malformed localStorage */ }
lt.cmdPalette.init(_cpCmds);
// Bind the header ⌘K trigger button (no inline onclick — CSP blocks inline handlers)
var _cmdTrigger = document.getElementById('lt-cmd-trigger');
if (_cmdTrigger) {
_cmdTrigger.addEventListener('click', function() { lt.cmdPalette.open(); });
}
}
// Patch lt.api mutating methods to auto-rotate CSRF token when server returns a new one
@@ -194,7 +210,7 @@
return Math.floor(diff / 86400) + 'd ago';
}
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
function renderNotifications(data) {
lt.notif.set(bell, data.unread_count || 0);
@@ -219,11 +235,12 @@
}
function loadNotifications() {
fetch('/api/notifications.php', { credentials: 'same-origin' })
return fetch('/api/notifications.php', { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(renderNotifications)
.then(function(data) { renderNotifications(data); return true; })
.catch(function() {
list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>';
return false;
});
}
@@ -245,9 +262,34 @@
document.addEventListener('click', function(e) { if (_open && wrapEl && !wrapEl.contains(e.target)) closePanel(); });
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && _open) closePanel(); });
// Initial badge count + poll every 60s
// Poll every 60s while the tab is visible, backing off (up to 5 min) on
// repeated failures, and resuming immediately when the tab regains focus.
var POLL_INTERVAL = 60000;
var MAX_POLL_INTERVAL = 300000;
var _pollTimer = null;
var _failCount = 0;
function scheduleNextPoll(delay) {
clearTimeout(_pollTimer);
_pollTimer = setTimeout(pollNotifications, delay);
}
function pollNotifications() {
if (document.hidden) return;
loadNotifications().then(function(ok) {
_failCount = ok ? 0 : _failCount + 1;
var delay = ok ? POLL_INTERVAL : Math.min(POLL_INTERVAL * Math.pow(2, _failCount), MAX_POLL_INTERVAL);
scheduleNextPoll(delay);
});
}
document.addEventListener('visibilitychange', function() {
if (!document.hidden) pollNotifications();
});
// Initial badge count, then start the poll cycle
loadNotifications();
setInterval(loadNotifications, 60000);
scheduleNextPoll(POLL_INTERVAL);
})();
<?php endif ?>
+3 -62
View File
@@ -196,7 +196,9 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Loading&hellip;</div>
</div>
<div class="lt-notif-panel-footer">
<?php if ($_lt_isAdmin) : ?>
<a href="/admin/audit-log" class="lt-btn lt-btn-ghost lt-btn-sm lt-w-full lt-text-center">View activity log</a>
<?php endif; ?>
</div>
</div>
</div>
@@ -212,67 +214,6 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
</header><!-- /.lt-header -->
<!-- ── COMMAND PALETTE OVERLAY (Ctrl+K / ⌘K) ──────────────────── -->
<div id="lt-cmd-overlay" class="lt-cmd-overlay" role="dialog" aria-modal="true" aria-label="Command palette" aria-hidden="true">
<div id="lt-cmd-palette" class="lt-cmd-palette" role="combobox" aria-expanded="true" aria-haspopup="listbox">
<div class="lt-cmd-input-wrap">
<span aria-hidden="true" style="opacity:0.45;margin-right:0.4rem;font-size:0.9em">&#x2315;</span>
<input class="lt-cmd-input" type="text" placeholder="Type a command or search&hellip;"
autocomplete="off" spellcheck="false" aria-label="Command search" aria-autocomplete="list"
aria-controls="lt-cmd-results-list">
<kbd style="font-size:0.6rem;opacity:0.4;white-space:nowrap">ESC</kbd>
</div>
<div class="lt-cmd-results" id="lt-cmd-results-list" role="listbox"></div>
</div>
</div>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
(function() {
var isAdmin = <?= json_encode($_lt_isAdmin) ?>;
document.addEventListener('DOMContentLoaded', function() {
var commands = [
{ id: 'nav-dashboard', label: 'Dashboard', icon: '⌂', group: 'Navigate', action: function(){ location.href = '/'; } },
{ id: 'nav-new-ticket', label: 'New Ticket', icon: '+', group: 'Navigate', kbd: 'N', action: function(){ location.href = '/create'; } },
{ id: 'filter-mine', label: 'My Open Tickets', icon: '◈', group: 'Filter', action: function(){ location.href = '/?assigned_to=me&status=Open,In+Progress,Pending'; } },
{ id: 'filter-unassigned', label: 'Unassigned Tickets', icon: '◌', group: 'Filter', action: function(){ location.href = '/?assigned_to=unassigned'; } },
{ id: 'filter-critical', label: 'P1 Critical Tickets', icon: '!', group: 'Filter', action: function(){ location.href = '/?priority=1'; } },
];
if (isAdmin) {
[
{ id: 'admin-templates', label: 'Admin: Templates', icon: '▤', href: '/admin/templates' },
{ id: 'admin-workflow', label: 'Admin: Workflow', icon: '⇌', href: '/admin/workflow' },
{ id: 'admin-audit', label: 'Admin: Audit Log', icon: '📋', href: '/admin/audit-log' },
{ id: 'admin-api-keys', label: 'Admin: API Keys', icon: '🔑', href: '/admin/api-keys' },
{ id: 'admin-users', label: 'Admin: User Activity', icon: '👤', href: '/admin/user-activity' },
{ id: 'admin-recurring', label: 'Admin: Recurring', icon: '↻', href: '/admin/recurring-tickets' },
{ id: 'admin-fields', label: 'Admin: Custom Fields', icon: '⊞', href: '/admin/custom-fields' },
].forEach(function(c) {
commands.push({ id: c.id, label: c.label, icon: c.icon, group: 'Admin', action: function(href){ return function(){ location.href = href; }; }(c.href) });
});
}
// Inject recent ticket IDs from localStorage
try {
var recent = JSON.parse(localStorage.getItem('lt_recent_tickets') || '[]');
recent.slice(0, 5).forEach(function(id) {
commands.push({ id: 'recent-' + id, label: 'Ticket #' + id, icon: '◷', group: 'Recent', tags: ['ticket'], action: function(tid){ return function(){ location.href = '/ticket/' + tid; }; }(id) });
});
} catch(_) {}
if (window.lt && lt.cmdPalette) lt.cmdPalette.init(commands);
// Bind the header ⌘K trigger here (no inline onclick — CSP blocks inline handlers)
var cmdTrigger = document.getElementById('lt-cmd-trigger');
if (cmdTrigger) {
cmdTrigger.addEventListener('click', function() {
if (window.lt && lt.cmdPalette) lt.cmdPalette.open();
});
}
});
// Keyboard shortcut: Ctrl+K / Cmd+K
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
if (window.lt && lt.cmdPalette) lt.cmdPalette.open();
}
});
})();
</script>
<!-- Command palette overlay + init live in layout_footer.php (single instance) -->
<main class="lt-main lt-container" id="main-content" style="padding-top: calc(var(--header-height, 56px) + var(--space-lg, 1.5rem))">