Compare commits

..
Author SHA1 Message Date
jaredandClaude Sonnet 5 1600412a6d Add a table-insert toolbar button to the markdown editor (#109)
Lint / PHP (phpcs PSR-12) (push) Successful in 31s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 29s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 3m1s
Lint / Deploy (push) Successful in 5s
The markdown toolbar offered bold/italic/code/heading/list/quote/link
but no table option, despite README describing table rendering as a
supported feature — the parser already renders manually-typed table
syntax correctly, this was purely a discoverability gap for a user who
wouldn't otherwise know the exact `| Header | Header |` / `|---|---|`
syntax to type from scratch.

Added toolbarTable(), which inserts a 2-column starter template (with
a leading newline only when needed, matching the table syntax's
requirement of a full line to itself) matching exactly what
parseMarkdownTables()'s detection regex expects, wired into the
toolbar's existing data-toolbar-action dispatch. Verified via jsdom
that the inserted template parses into a real HTML table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 15:11:03 -04:00
jaredandClaude Sonnet 5 803c65616b Update stale README Project Structure tree and migrations docs (#45)
Cross-checking README.md against the actual file tree found three gaps:
views/error_403.php and error_404.php (plus error_500.php, added since
the issue was filed) weren't listed under views/; config/requirements.php
wasn't listed under config/ (confirmed it's not a duplicate of
scripts/check_requirements.php — it's the shared data source both that
script and api/health.php read from); and the Database Schema section
only described 000_baseline.sql and migrate.php generically, with no
mention that four numbered migrations now exist on top of the baseline.

Updated the Project Structure tree and the Database Schema/Migrations
prose to list all of these, noting that 000_baseline.sql already
includes every numbered migration's changes for a fresh install (they
only matter when upgrading an existing database).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 15:10:56 -04:00
jaredandClaude Sonnet 5 0e163f6607 Remove unused lt.markdown module (#43)
lt.markdown was confirmed dead code app-wide (zero callers outside
base.js itself — markdown.js's parseMarkdown() is what's actually
wired up everywhere). The issue flagged its link handler as lacking a
URL-protocol allowlist unlike markdown.js's equivalent; checking the
current code, that link handler already restricts to http(s)/relative/
hash URLs (blocking javascript:/data: URIs) — the allowlist claim
didn't match what's actually there. Since the module is unused either
way, and its own doc comment invites exactly the kind of future
misuse the issue warned about ("For full GFM, swap in marked.js"),
deleted it outright rather than hardening dead code, removing the
landmine permanently instead of leaving an unused copy that could
still drift out of sync with markdown.js in some other way later.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 15:10:47 -04:00
jaredandClaude Sonnet 5 e1448d8ea2 Document intentional empty catches, fix one real gap, allow == null in eqeqeq (#44)
ESLint flagged ~20 empty catch blocks and 4 loose-equality comparisons
in assets/js/. Auditing each: all ~19 remaining empty catches are
localStorage/sessionStorage access (persisted tab/theme/column-
visibility state, recent command-palette entries) or the terminal
beep's AudioContext calls — genuinely intentional best-effort UX
affordances that must silently no-op if storage is disabled/full or
audio is blocked, not oversights. One (a viewport-change listener
callback) was a real gap: swallowing an arbitrary caller-supplied
callback's exception could hide a genuine bug, so that one now logs
via console.error instead.

Documented the storage/audio convention once in a file-level comment
rather than repeating the same explanation on ~19 near-identical
one-line try/catches. All 4 flagged loose-equality comparisons turned
out to be `== null`/`!= null` checks — the one loose-equality idiom
that's deliberately safe (catches both null and undefined in one
comparison; ESLint's own eqeqeq rule has a "smart" mode specifically
for this). Converting them to strict equality would have been a
behavior change (no longer catching undefined), not a fix, so switched
.eslintrc.json's eqeqeq rule to "smart" instead — flags every other
loose comparison as before, correctly stops flagging this one safe
idiom.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 15:08:50 -04:00
jared b6c17096b5 Merge development into main: search filter merge, workflow dup rejection, recurring-ticket loss alert, preview debounce (#58, #62, #88, #108)
Lint / PHP (phpcs PSR-12) (push) Successful in 32s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 27s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m33s
Lint / Deploy (push) Successful in 2s
- Merge into current URL params instead of replacing them in Advanced Search (#58)
- Reject duplicate workflow transitions with a clear error (#62)
- Alert and record a lost recurring-ticket occurrence on creation failure (#88)
- Debounce the live markdown preview (#108)
2026-09-11 14:49:44 -04:00
jaredandClaude Sonnet 5 6bd1bb082a Debounce the live markdown preview (#108)
Lint / PHP (phpcs PSR-12) (push) Successful in 28s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 31s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m31s
Lint / Deploy (push) Successful in 2s
updatePreview() was bound directly to the comment textarea's 'input'
event with no debounce, re-running the full markdown parser (regex
passes for headings, tables, links, footnotes, etc.) on every single
keystroke.

Wrapped it with the existing lt.debounce() helper (150ms) — the
initial preview render on enabling the toggle still happens
immediately; only the per-keystroke live updates are debounced.
Verified via jsdom with real timers: 10 rapid keystrokes within the
debounce window produce exactly one parse call instead of ten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 14:28:39 -04:00
jaredandClaude Sonnet 5 a4828c1b7b Alert and record a lost recurring-ticket occurrence on creation failure (#88)
RecurringTicketModel::claimForRun() deliberately advances next_run_at
before TicketModel::createTicket() runs, to prevent duplicate-ticket
floods if creation fails partway and the cron retries. The tradeoff:
if createTicket() then fails, that specific occurrence is gone forever
with no record anywhere an admin would normally look — the catch
block only wrote a line to stdout/the cron log.

Added recordMissedOccurrence(), called from both the "createTicket()
returned success:false" branch and the exception catch, which writes
an audit_log entry (entity_type='recurring_ticket', action_type='error')
and fires a new NotificationHelper::sendSystemAlert() — a generic
operational alert (unlike the ticket-specific notification methods,
it has no associated ticket) sent to the shared MATRIX_NOTIFY_USERS
list regardless of any per-event toggle, so a silently-skipped
recurring ticket surfaces immediately instead of requiring someone to
grep cron logs.

Verified against real MariaDB and a real webhook-capturing server:
calling the recorder writes the audit_log row with the failure reason
and schedule details, and fires the Matrix alert with the same
information.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 14:28:31 -04:00
jaredandClaude Sonnet 5 86ef91abcb Reject duplicate workflow transitions with a clear error (#62)
status_transitions already has a DB-level UNIQUE KEY on
(from_status, to_status), so a genuine duplicate pair was never
actually possible to insert — but hitting that constraint raw
surfaced as an opaque "An internal error occurred" to the admin
instead of a clear message, since manage_workflows.php only validated
from_status !== to_status before attempting the insert/update.

Added an explicit existence check before insert/update in both the
POST and PUT handlers (excluding the row's own ID on update), so the
common case — an admin re-adding or renaming into a pair that already
exists — gets a specific 409 with the conflicting pair named, instead
of a generic 500. Also added ORDER BY transition_id to
WorkflowModel::getAllTransitions() as a defense-in-depth backstop:
since it collapses rows into a PHP array keyed by
[from_status][to_status] with no defined winner otherwise, if the DB
constraint were ever weakened or bypassed, this at least makes which
row wins deterministic (most recently created).

Verified against a real running server + real MariaDB: creating a
duplicate active pair, a duplicate inactive pair, and updating a
different row into an existing pair are all correctly rejected with
the friendly message; updating a row to keep its own existing pair
succeeds; and a genuinely different pair still creates normally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 14:28:21 -04:00
jaredandClaude Sonnet 5 6d68af40e7 Merge into current URL params instead of replacing them in Advanced Search (#58)
Same root pattern as the earlier chart click-to-filter bug (#29):
performAdvancedSearch() built a brand-new URLSearchParams from only the
form's own fields and navigated to it, silently dropping any active
filter the form doesn't represent (e.g. a category/type filter applied
via a dashboard quick-filter pill or stats-widget click).
populateCurrentFilters() also only read search/status back out of the
URL into the form, not the date ranges/priority range/user fields the
form does control.

Fixed performAdvancedSearch() to start from the current URL's params
and only set/clear the ones this form actually represents, leaving
everything else untouched. Also fixed populateCurrentFilters() to
restore all of those fields, not just search/status — without that,
reopening the modal and submitting without touching anything would
now silently wipe date/priority/user filters that were active but
shown blank in the form (a new foot-gun the first fix alone would have
introduced).

Verified via jsdom: category/type/sort params not represented in the
form survive a search submission; page resets to 1; and reopening the
modal with an active created_from filter correctly restores it into
the form and preserves it on a no-op resubmit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 14:28:12 -04:00
jared aa8173941a Merge development into main: webhook timeout, comment-edit timeline fix, Bearer rate-limiting overhaul (#77, #80, #81, #82, #83, #87)
Lint / PHP (phpcs PSR-12) (push) Successful in 43s
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP requirements (version + extensions) (push) Successful in 30s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m4s
Lint / Deploy (push) Successful in 3s
- Add connect-timeout to Matrix webhook calls (#77)
- Include ticket_id in comment-edit audit log so it appears on the timeline (#87)
- Overhaul Bearer API rate limiting: real config, per-key isolation, skip session (#80, #81, #82, #83)
2026-09-11 14:11:48 -04:00
jaredandClaude Sonnet 5 1b1801696f Overhaul Bearer API rate limiting: real config, per-key isolation, skip session (#80, #81, #82, #83)
Lint / PHP (phpcs PSR-12) (push) Successful in 28s
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP requirements (version + extensions) (push) Successful in 30s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 3m9s
Lint / Deploy (push) Successful in 2s
Four interrelated gaps in the same rate-limiting path:

- #80: RATE_LIMIT_DEFAULT/RATE_LIMIT_API were defined in config.php but
  RateLimitMiddleware never read them (hardcoded class constants
  instead), and they weren't in .env.example — a deployer editing them
  saw zero effect with no documented way to actually change the limit.
- #81: Bearer traffic was rate-limited purely by a shared IP bucket
  (the session-based half was a no-op for stateless clients, since a
  fresh session starts on every request). Two different API keys from
  the same host/NAT egress IP shared ONE bucket, so a chatty or
  misbehaving key could 429 a completely unrelated key's traffic.
- #82: X-RateLimit-* headers reported the meaningless session counter
  for Bearer clients instead of whatever bucket actually governed them.
- #83: RateLimitMiddleware::check() called session_start()
  unconditionally, before ApiKeyAuth even runs — continuous session-file
  churn and an unnecessary Set-Cookie on every stateless API request,
  using un-hardened cookie defaults since it runs before
  AuthMiddleware's hardening (which Bearer requests never reach anyway).

Fixed as one pass since they're the same code path: config.php now
reads RATE_LIMIT_DEFAULT/RATE_LIMIT_API from .env (added there too,
documented); the middleware now extracts the raw Bearer token
(independent of ApiKeyAuth, so no DB round-trip needed before rate
limiting, and it works whether or not the token later turns out
valid) and rate-limits it via its own per-token bucket instead of
starting a session — the existing IP-based bucket still applies
underneath as defense-in-depth against volumetric abuse from one
network path, but each distinct key now gets real isolated headroom.
getStatus()/addHeaders() report that per-token bucket for Bearer
requests instead of the session counter.

Verified: a Bearer request creates zero session files (confirmed via
real session-directory file count before/after); two different keys
from different IPs are fully isolated (one exhausting its own 120/min
bucket has zero effect on the other); a config-driven RATE_LIMIT_API
override (e.g. 5) is correctly honored for session-based (non-Bearer)
traffic; X-RateLimit-* status correctly reflects the per-key bucket
for a Bearer request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 14:05:14 -04:00
jaredandClaude Sonnet 5 09cea2b388 Include ticket_id in comment-edit audit log so it appears on the timeline (#87)
AuditLogModel::getTicketTimeline() requires, for entity_type='comment'
rows, that details.ticket_id match the ticket being viewed.
logCommentCreate() and delete-comment's audit call both correctly
include it; update_comment.php's audit call only set
comment_text_preview, so an edited comment's audit row was written
(visible in the admin's global Audit Log) but never matched the
timeline's join condition — a comment edit left no trace on the
ticket's own history, while deleting the same comment would be
visible.

Added ticket_id to the details array, using $comment['ticket_id']
already loaded earlier in the file for the access check. Verified
against real MariaDB: the fixed shape now correctly appears in
getTicketTimeline()'s results.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 14:05:01 -04:00
jaredandClaude Sonnet 5 9702aafacd Add connect-timeout to Matrix webhook calls (#77)
NotificationHelper::fire() set CURLOPT_TIMEOUT (10s total) but no
CURLOPT_CONNECTTIMEOUT, so a slow-but-not-hung hookshot endpoint could
add up to the full 10s per fire() call — and a single request can call
fire() more than once sequentially (e.g. add_comment.php firing
mention + comment + watcher notifications back to back), stacking into
tens of seconds of added latency on the user-facing response.

Added a 3s CURLOPT_CONNECTTIMEOUT so a slow-to-connect endpoint fails
fast without needing the full request to time out. Verified the
webhook still fires correctly end-to-end against a real local HTTP
server after the change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 14:04:52 -04:00
jared 66bf82bf46 Merge development into main: visibility-notification pruning + CSRF UX + custom field type validation (#48, #50, #57, #73, #86)
Lint / PHP (phpcs PSR-12) (push) Successful in 30s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 32s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 3m22s
Lint / Deploy (push) Successful in 6s
- Prune watchers when a ticket's visibility is tightened (#73)
- Re-check ticket visibility before surfacing in-app notifications (#48)
- Use lt.api instead of raw fetch() in notification bell (#57)
- Auto-retry once after CSRF token resync in lt.api (#86)
- Validate field_type against the allowed enum in custom field definitions (#50)
2026-09-11 13:48:20 -04:00
jaredandClaude Sonnet 5 fca0b42726 Validate field_type against the allowed enum in custom field definitions (#50)
Lint / PHP (phpcs PSR-12) (push) Successful in 31s
Lint / JS (eslint) (push) Successful in 13s
Lint / PHP requirements (version + extensions) (push) Successful in 39s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m1s
Lint / Deploy (push) Successful in 6s
The setValue()/is_required/select-options half of this issue was
already fixed incidentally by #47's new api/ticket_custom_fields.php
endpoint. The remaining gap: createDefinition()/updateDefinition()
never validated field_type against the six values the schema's
enum() actually allows (text/textarea/select/checkbox/date/number), so
a malformed type could be stored via the admin API and break whatever
UI renders it later.

Added an ALLOWED_FIELD_TYPES allowlist check at the top of both
methods, returning the same ['success' => false, 'error' => ...] shape
they already use for a DB failure — api/custom_fields.php already
propagates that shape correctly with no changes needed there. Verified
against real MariaDB: an invalid field_type is rejected on both create
and update, while a valid one still succeeds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 13:31:33 -04:00
jaredandClaude Sonnet 5 ae12fcd6fd Auto-retry once after CSRF token resync in lt.api (#86)
lt.api's fetch wrapper (_apiFetchAuth in base.js — the live
implementation lt.api.* resolves to) already resynced
window.CSRF_TOKEN from a 403 response's csrf_token field, but still
threw immediately — every caller saw a raw "Invalid CSRF token" error
on the FIRST attempt, with no transparent retry. Since CsrfMiddleware's
token lifetime (1h) is shorter than the session idle timeout (5h),
this was a routine, fully recoverable case (an hour of page
inactivity, or a write in another tab rotating the shared token), not
a real rejection.

After resyncing the token from a 403 body that carries one, now
retries the original request exactly once with the fresh token before
surfacing an error — transparent to the caller on the common case,
with a `retried` flag preventing more than one retry so a genuinely
broken session still fails cleanly instead of looping. Verified via
jsdom with a mocked fetch: a 403-then-succeeds sequence resolves
successfully with exactly 2 network calls and the correct final
token; a persistently-403 sequence still throws after exactly 2 calls
(no infinite retry).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 13:31:25 -04:00
jaredandClaude Sonnet 5 5b96e75ff6 Use lt.api instead of raw fetch() in notification bell (#57)
layout_footer.php's loadNotifications() and "mark all read" handler
called fetch() directly instead of lt.api.*, violating the project's
own documented convention (README Dev Notes #20). api/bootstrap.php
rotates the CSRF token on every successful write and returns it in the
response's csrf_token field; lt.api.* reads that and updates
window.CSRF_TOKEN, but a raw fetch() never does — so after "mark all
read", the server had rotated its token but the client's cached one
was stale, causing the user's next write anywhere else in the app to
fail once with "Invalid CSRF token" before self-healing.

Replaced both fetch() calls with lt.api.get/post, which also drops the
now-redundant manual header/credentials boilerplate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 13:31:15 -04:00
jaredandClaude Sonnet 5 3db3749c46 Re-check ticket visibility before surfacing in-app notifications (#48)
All four notification queries in api/notifications.php (assign,
comment, status-change, mention) were scoped purely by
created_by/assigned_to/ticket_watchers membership and historical
audit_log contents — never by canUserAccessTicket(). If a ticket's
visibility was later tightened, or a user's group/watcher access
revoked, a notification still surfaced in their bell dropdown,
disclosing the ticket's title and that activity occurred even though
opening the ticket itself would now be blocked.

Batch-fetches the tickets referenced by all candidate notifications
(via the existing getTicketsByIds()) and filters out any whose current
state canUserAccessTicket() would reject for the requesting user,
before formatting the response — so a notification for a ticket the
user can no longer see simply disappears rather than lingering as a
disclosure. Verified against real MariaDB with a running server: an
assignment notification is visible while the user is the assignee of
a public ticket, and disappears once the ticket is reassigned away and
made confidential.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 13:31:08 -04:00
jaredandClaude Sonnet 5 9e83f8903a Prune watchers when a ticket's visibility is tightened (#73)
TicketModel::updateVisibility() only updated the tickets row — it
never touched ticket_watchers. A user watching a public ticket that's
later made confidential/internal, and who isn't creator/assignee/
admin/in the new visibility_groups, kept receiving Matrix
notifications (title + redacted activity preview) about a ticket
canUserAccessTicket() would now reject them from opening directly.

After a successful visibility update, re-evaluates every current
watcher against the new visibility rules via the same
canUserAccessTicket() check the rest of the app uses, and removes any
who no longer qualify. Verified against real MariaDB: tightening to
confidential correctly drops watchers with no standing access while
keeping an admin watcher; tightening to internal with a specific group
correctly keeps a watcher in that group and drops one who isn't.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 13:30:59 -04:00
jared cabdae35cc Merge development into main: Custom Fields wiring (#47)
Lint / PHP (phpcs PSR-12) (push) Successful in 31s
Lint / JS (eslint) (push) Successful in 14s
Lint / PHP requirements (version + extensions) (push) Successful in 53s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m43s
Lint / Deploy (push) Successful in 4s
- Wire Custom Fields into ticket creation and viewing (#47)
2026-09-11 13:17:33 -04:00
jaredandClaude Sonnet 5 3266373cdf Wire Custom Fields into ticket creation and viewing (#47)
Lint / PHP (phpcs PSR-12) (push) Successful in 42s
Lint / JS (eslint) (push) Successful in 13s
Lint / PHP requirements (version + extensions) (push) Successful in 27s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m24s
Lint / Deploy (push) Successful in 3s
CustomFieldModel::setValue()/setValues()/getValuesForTicket() were
never called anywhere outside api/custom_fields.php's own admin CRUD
for field *definitions* — CreateTicketView.php never rendered custom
fields, TicketController::create() never collected or saved them, and
TicketView.php never displayed them. The whole feature (admin defines
fields at /admin/custom-fields, including marking them Required) was
config-only with zero consumer; is_required was enforced nowhere.

Added:
- api/ticket_custom_fields.php: new endpoint (bootstrap.php-based, so
  any ticket editor can use it, not just admins) that saves values for
  a ticket. Only considers fields applicable to the ticket's current
  category (or category-less fields); enforces is_required, validates
  select values against the field's configured options, and validates
  number fields are numeric. Values for fields that don't apply are
  silently ignored rather than persisted, so a value typed before a
  category change can't linger as orphaned data.
- CreateTicketView.php: renders every active field definition (all
  categories, since the ticket doesn't exist yet), grouped with a
  data-custom-field-category attribute and toggled client-side as the
  Category select changes, matching the existing visibility-groups
  toggle pattern. Submitted as part of the same form.
- TicketController::create(): validates is_required server-side against
  the fields applicable to the *submitted* category (not just whatever
  was visible client-side) before creating the ticket, then persists
  via CustomFieldModel::setValues() after a successful create.
- TicketView.php: new "Custom Fields" tab (only shown when the ticket's
  category has applicable fields) rendering current values with a
  single Save button, calling the new endpoint — a panel-plus-save
  interaction rather than per-field inline auto-save, to keep scope
  contained across 6 field types.

Verified against real MariaDB and a real running server: a required
field left blank is correctly rejected (both at ticket-creation time
and when editing an existing ticket) with no partial write; a valid
submission persists exactly the fields applicable to that ticket's
category; an invalid select value is rejected without touching
previously-saved values; and each rejection correctly returns a
rotated recovery csrf_token (initially missed on the validation-error
paths, since they used a plain echo/exit instead of the bootstrap.php
apiRespond() helper every other endpoint's error paths use for this).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 13:12:42 -04:00
jared f342e446d3 Merge development into main: bulk-op notification/audit fixes + workflow-validated auto-reopen (#67, #68, #74)
Lint / PHP (phpcs PSR-12) (push) Successful in 35s
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP requirements (version + extensions) (push) Successful in 27s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m27s
Lint / Deploy (push) Successful in 2s
- Fire notifications and fix audit_log shape for bulk status changes (#67, #74)
- Route hwmonDaemon's auto-reopen through Workflow Designer validation (#68)
2026-09-11 12:44:06 -04:00
jaredandClaude Sonnet 5 18c213ebd7 Route hwmonDaemon's auto-reopen through Workflow Designer validation (#68)
Lint / PHP (phpcs PSR-12) (push) Successful in 50s
Lint / JS (eslint) (push) Successful in 14s
Lint / PHP requirements (version + extensions) (push) Successful in 34s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m25s
Lint / Deploy (push) Successful in 8s
create_ticket_api.php's dedup-reopen path wrote status = 'Open' via a
raw SQL UPDATE, completely bypassing TicketModel::updateTicket() and
WorkflowModel::isTransitionAllowed() — the one status-write path in
the app that never consulted the workflow engine at all. If an admin
configured the Workflow Designer to disallow a direct Closed->Open
transition, this automated path still forced it unconditionally.

Now checks isTransitionAllowed('Closed', 'Open', false) first (false
since this is an unattended system account, not admin-elevated). If
not allowed, falls back to any transition the Workflow Designer does
allow from Closed that requires neither a comment nor admin privilege
(both of which this unattended automation can't satisfy), and applies
it via TicketModel::updateTicket() instead of raw SQL. If no such
transition exists at all, the ticket is deliberately left Closed
(rather than forcing an unconfigured state) with a comment and audit
entry explaining why, so the recurrence is still visible to a human
without silently violating workflow rules.

Verified against real MariaDB across all three branches: direct
Closed->Open allowed (reopens to Open), disallowed but Closed->'In
Progress' available unattended (falls back correctly), and no usable
transition configured at all (ticket correctly stays Closed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 12:37:46 -04:00
jaredandClaude Sonnet 5 6adbb29964 Fire notifications and fix audit_log shape for bulk status changes (#67, #74)
BulkOperationsModel's bulk_close/bulk_status paths had zero references
to NotificationHelper — the exact same status transition (e.g.
Open->Closed) silently produced no Matrix/watcher notification when
performed via bulk actions, while the single-ticket edit page and
Bearer API both notify on every status change. Separately, their
audit_log entries used a bare ['status' => 'Closed', ...] shape
instead of the {'status': {'from': X, 'to': Y}} shape every other
status-change path uses, which broke two downstream consumers:
TicketView.php's timeline fell back to a generic "updated this
ticket" instead of "updated status", and notifications.php's
$details['status']['from'] on a string produced a broken "? -> ?"
notification title.

Fixed the audit_log shape for both operation types, and added a
notification queue collected during the per-ticket loop and flushed
only after a successful commit (so atomic-mode rollback correctly
sends zero notifications, matching how nothing else about a rolled-
back batch takes effect either). Also fixed an incidental bug found
while matching this to the single-ticket path: update_ticket.php's
notifyWatchers() call never passed the ticket's visibility, silently
defaulting to 'public' and always including the shared notify list
even for confidential/internal tickets — the exact leak #71 fixed
elsewhere in NotificationHelper itself, just never reaching this
call site.

Verified against real MariaDB with a real local webhook-capturing
server: bulk_close correctly fires sendStatusChangeNotification() +
notifyWatchers() with the right old/new status and a redacted title
for a confidential ticket; audit_log rows show the correct {from,to}
shape; and an atomic-mode rollback (one ticket's transition invalid)
sends zero notifications and leaves both tickets unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 12:37:37 -04:00
jared 5a69c41f48 Merge development into main: error-handling rollout + session privilege re-sync (#38, #39, #56, #105)
Lint / PHP (phpcs PSR-12) (push) Successful in 44s
Lint / JS (eslint) (push) Successful in 15s
Lint / PHP requirements (version + extensions) (push) Successful in 1m0s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m49s
Lint / Deploy (push) Successful in 2s
- Complete ErrorHandler rollout: wire into all endpoints, fix display_errors gaps, add styled 500 page (#38, #39, #105)
- Periodically re-sync session privileges from Authelia (#56)
2026-09-11 12:23:49 -04:00
jaredandClaude Sonnet 5 6b7e67eee4 Periodically re-sync session privileges from Authelia (#56)
Lint / PHP (phpcs PSR-12) (push) Successful in 25s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 1m7s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m44s
Lint / Deploy (push) Successful in 3s
AuthMiddleware::authenticate() only re-read Remote-User/Remote-Groups
(and thus is_admin, via UserModel::syncUserFromAuthelia) when
$_SESSION['user'] didn't exist yet. Once a session existed, every
subsequent request only checked the idle timer — never re-validating
against current Authelia/LLDAP state. An admin's group membership
revoked in LLDAP, or a logout at the Authelia proxy, left their
already-open session with full access for up to SESSION_TIMEOUT (5h
default), with no way to force early revocation short of clearing the
server-side session store.

Added PRIVILEGE_RESYNC_INTERVAL (default 5 min, matching
UserModel's own cache TTL) and a resyncPrivileges() check on every
already-authenticated request past that interval: re-reads the current
request's forward-auth headers (enforcing the trusted-proxy check
again, same as a fresh login), and either destroys the session and
redirects to re-auth if the user no longer has any required group, or
re-syncs is_admin/groups/display_name/email if they do. Best-effort if
this particular request doesn't carry forward-auth headers at all
(skips silently rather than force-logging out, retried next interval).

UserModel::syncUserFromAuthelia() has its own 5-minute in-process
cache keyed only by username (not by the groups being synced), so a
naive re-call during a resync would have kept returning the
pre-revocation cached result for up to 5 more minutes — invalidated
that cache entry immediately beforehand to guarantee a real re-sync.

Verified against real MariaDB across a fresh login, a same-interval
request confirming no premature resync, an admin-privilege-revocation
mid-session (is_admin flips to false in both session and DB, verified
via a direct query), and a full group-membership revocation (session
destroyed, redirected to re-auth, confirmed the request never reaches
past that point).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 12:17:27 -04:00
jaredandClaude Sonnet 5 71bf64c1e2 Complete ErrorHandler rollout: wire into all endpoints, fix display_errors gaps, add styled 500 page (#38, #39, #105)
README documented ErrorHandler.php as a "global error/exception
handler", but ErrorHandler::init() had exactly one caller app-wide
(api/get_template.php). 13 endpoints never called
ini_set('display_errors', 0) at all, relying on the server's global
php.ini default, and index.php never registered any handler — a
genuine PHP fatal during a page render fell through to PHP's raw
default handling with no app-level 500 response, styled or otherwise.

Investigating the "13 endpoints" claim turned up that 9 of them
(assign_ticket.php, audit_log.php, check_duplicates.php,
get_comments.php, get_users.php, notifications.php, saved_filters.php,
user_preferences.php, watch_ticket.php) already require
api/bootstrap.php as their first statement, which itself calls
ini_set('display_errors', 0) — so they were never actually exposed;
the static grep just couldn't see through the require. The 3 that
were genuinely unprotected (bulk_operation.php, download_attachment.php,
health.php) are fixed here. ticket_dependencies.php already has its
own complete hand-rolled equivalent (shutdown handler, error handler,
exception handler, output-buffer aware) and was deliberately left
alone rather than risk double-registering handlers.

Rather than duplicate the fix 30+ times, wired ErrorHandler::init()
directly into api/bootstrap.php (covering all 9 files above at once)
and into each of the other endpoints' own ini_set/error_reporting
pair, replacing it in place — additive only: existing try/catch blocks
in every endpoint still handle what they already handled identically,
this only adds a safety net for genuinely uncaught fatals that fell
through everything else. Before doing this app-wide, removed
ErrorHandler::init()'s override of PHP's 'error_log' ini setting: it
redirected every error_log() call in the request to a fixed /tmp file,
which would have silently diverted logs away from wherever the server
is actually configured to send them the moment this got wired into
more than one endpoint. That override only existed to support
getRecentErrors(), which has zero callers app-wide.

For index.php (page views, not JSON), added an 'html' response mode to
ErrorHandler that renders a new views/error_500.php instead of a JSON
body. That view is deliberately self-contained (no layout_header.php,
no $GLOBALS/session/DB dependency) since a genuine fatal can happen
before config.php finishes loading or mid-session-start.

Verified: a real uncaught error with no prior output correctly
produces a clean JSON 500 (API mode) or the styled HTML page (page
mode) to the client while the full stack trace goes to error_log, not
the response; normal (non-fatal) requests through both a
bootstrap.php-based endpoint and index.php are byte-for-byte
unaffected. Full project phpcs pass is clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 12:17:15 -04:00
jared bcc732e605 Merge development into main: connection/security hardening batch (#85, #94, #103, #104)
Lint / PHP (phpcs PSR-12) (push) Successful in 25s
Lint / JS (eslint) (push) Successful in 11s
Lint / PHP requirements (version + extensions) (push) Successful in 43s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m44s
Lint / Deploy (push) Successful in 2s
- Route index.php and create_ticket_api.php through Database::getConnection() (#103, #104)
- Make TRUSTED_PROXIES' insecure-by-default risk loudly visible (#94)
- Add recovery csrf_token to 12 hand-rolled CSRF rejection responses (#85)
2026-09-11 11:54:36 -04:00
jaredandClaude Sonnet 5 9d8a73c355 Add recovery csrf_token to 12 hand-rolled CSRF rejection responses (#85)
Lint / PHP (phpcs PSR-12) (push) Successful in 38s
Lint / JS (eslint) (push) Successful in 15s
Lint / PHP requirements (version + extensions) (push) Successful in 38s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m27s
Lint / Deploy (push) Successful in 2s
api/bootstrap.php's centralized CSRF handling echoes
CsrfMiddleware::getToken() on a 403 rejection specifically so
lt.api's client-side resync (assets/js/base.js) can recover once
window.CSRF_TOKEN goes stale (token expiry, or a write in another tab
rotating the shared session-scoped token). 12 endpoints duplicate
CsrfMiddleware::validateToken() inline instead of routing through
bootstrap.php, and their 403 body omitted csrf_token entirely —
custom_fields.php, clone_ticket.php, delete_comment.php,
delete_attachment.php, bulk_operation.php, generate_api_key.php,
manage_templates.php, manage_recurring.php, revoke_api_key.php,
manage_workflows.php, ticket_dependencies.php, and
upload_attachment.php.

Once a client's token drifted out of sync, the next write to any of
these 12 endpoints returned a 403 with no way to self-heal — every
subsequent write to any endpoint kept failing until a manual reload,
since the resync mechanism was only wired up on a minority of the
app's write surface. Took the minimal fix the issue names as
sufficient (add 'csrf_token' => CsrfMiddleware::getToken() to each
rejection body) rather than restructuring all 12 through bootstrap.php,
to avoid behavioral risk from rewiring each endpoint's differing
auth/bootstrapping. generate_api_key.php and revoke_api_key.php threw
a generic Exception for this case (swallowed into a plain error-message
response with no room for extra fields), so those two now short-circuit
with a direct JSON response instead, matching the other 10.

Verified end-to-end against real running endpoints with a real
session and real MariaDB: sent a wrong CSRF token to one endpoint of
each response shape (plain json_encode, ResponseHelper::error, and the
formerly exception-based path) and confirmed all three now return the
current valid csrf_token in the 403 body.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 11:42:46 -04:00
jaredandClaude Sonnet 5 c78d24154a Make TRUSTED_PROXIES' insecure-by-default risk loudly visible (#94)
TRUSTED_PROXIES ships empty in .env.example, which disables
AuthMiddleware's reverse-proxy allowlist entirely — a fresh deployment
that doesn't explicitly set it has zero verification that
Remote-User/Remote-Groups headers actually came from the trusted
Authelia proxy. Anything that can reach the app directly (a
misconfigured firewall rule, an exposed container port, SSRF from
another internal service) can set Remote-User: admin and fully
impersonate any user with zero authentication. The enforcement logic
itself was already correct; this was purely a dangerous, easy-to-miss
default.

Added a boxed, unmissable warning around TRUSTED_PROXIES in
.env.example (previously just an inline comment easy to skim past),
added the same warning to README's setup instructions (which didn't
mention this variable at all), and added a Check 8 to api/health.php
that reports a 'warning' status when TRUSTED_PROXIES is empty, so a
deployment that forgets it doesn't go unnoticed after the fact.
Verified against real MariaDB via a running server: the health
endpoint correctly reports 'warning' when empty and 'ok' once set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 11:42:29 -04:00
jaredandClaude Sonnet 5 98d30cbc58 Route index.php and create_ticket_api.php through Database::getConnection() (#103, #104)
Both files opened their own raw new mysqli(...) connection instead of
using Database::getConnection(), missing the charset/timezone sync
every other connection gets. index.php's connection serves nearly all
non-API web traffic (dashboard, ticket view, ticket create) — any
NOW()/CURDATE()-based query through it used the DB server's default
session timezone instead of the app's configured TIMEZONE, and no
explicit utf8mb4 charset meant multi-byte characters typed into a
ticket via the non-JS POST fallback could get corrupted at write time.
create_ticket_api.php (the hwmonDaemon Bearer endpoint) had the same
timezone gap, risking created_at landing on the wrong 'day' relative
to every other ticket-creation path.

index.php's raw die("Connection failed: " . $conn->connect_error) also
leaked raw mysqli error text (host/user/failure reason) to any
unauthenticated visitor on a DB outage; it now logs via error_log()
and shows a generic message instead. create_ticket_api.php already
handled this correctly (JSON error + error_log, no leak) and needed no
behavior change there beyond the connection source.

Verified against real MariaDB: the new connection path reports the
configured -04:00 session time_zone and utf8mb4 charset, vs. SYSTEM
tz on the old raw-mysqli path; a simulated connection failure (bad
DB_NAME) confirmed only the generic message reaches the response body
while the raw driver error goes to error_log().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 11:42:21 -04:00
49 changed files with 1187 additions and 258 deletions
+17 -4
View File
@@ -49,19 +49,26 @@ APP_DOMAIN=
; Include all domains that can access this application
ALLOWED_HOSTS=localhost,127.0.0.1
; ============================================================================
; REQUIRED FOR PRODUCTION -- READ BEFORE DEPLOYING -- TRUSTED_PROXIES
; ============================================================================
; 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.
; trusts Remote-User / Remote-Groups headers from ANY source. If the PHP
; backend is reachable directly -- a misconfigured firewall rule, a container
; network accidentally exposing the port, SSRF from another internal service
; -- ANYONE can set Remote-User: admin themselves and fully impersonate any
; user, including an admin, with ZERO authentication. Only leave it empty when
; network topology guarantees PHP is reachable solely via the trusted proxy
; (e.g. local development), never in a real deployment.
;
; 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)
@@ -77,3 +84,9 @@ 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
; Session-based rate limits (requests per 60s window). These govern
; browser/session traffic on general and API endpoints respectively;
; Bearer-key API traffic is rate-limited separately, per API key.
RATE_LIMIT_DEFAULT=100
RATE_LIMIT_API=60
+1 -1
View File
@@ -20,6 +20,6 @@
"no-useless-escape": "warn",
"no-regex-spaces": "warn",
"semi": ["error", "always"],
"eqeqeq": "warn"
"eqeqeq": ["warn", "smart"]
}
}
+26 -1
View File
@@ -255,6 +255,7 @@ Content-Type: application/json
- `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.
- Numbered migrations on top of the baseline (all idempotent, safe to re-run): `001_widen_bulk_operations_status.sql`, `002_fix_collation_consistency.sql`, `003_fk_on_delete_set_null.sql`, `004_fix_ticket_watchers_type.sql`. A fresh install via `000_baseline.sql` already includes all of these; they only matter for upgrading an existing database.
### API Endpoints
@@ -348,7 +349,9 @@ tinker_tickets/
│ └── images/
│ └── favicon.png
├── config/
── config.php # Config + .env loading
── config.php # Config + .env loading
│ └── requirements.php # PHP version/extension requirements (single source of
│ # truth for scripts/check_requirements.php + api/health.php)
├── controllers/
│ ├── CommentController.php # Comment create/edit/delete + notifications
│ ├── DashboardController.php # Dashboard with stats + filters
@@ -389,6 +392,10 @@ tinker_tickets/
│ └── WorkflowModel.php # Status transition workflows
├── migrations/
│ ├── 000_baseline.sql # Full schema baseline (safe to re-run)
│ ├── 001_widen_bulk_operations_status.sql # Upgrade-only (already in baseline for fresh installs)
│ ├── 002_fix_collation_consistency.sql # Upgrade-only (already in baseline for fresh installs)
│ ├── 003_fk_on_delete_set_null.sql # Upgrade-only (already in baseline for fresh installs)
│ ├── 004_fix_ticket_watchers_type.sql # Upgrade-only (already in baseline for fresh installs)
│ └── migrate.php # CLI migration runner (tracks applied migrations)
├── scripts/
│ ├── check_requirements.php # Verify PHP extensions/config prerequisites
@@ -406,6 +413,9 @@ tinker_tickets/
│ │ └── WorkflowDesignerView.php # Workflow transition designer
│ ├── CreateTicketView.php # Ticket creation with visibility
│ ├── DashboardView.php # Dashboard with kanban + sidebar + charts
│ ├── error_403.php # Access-denied error page
│ ├── error_404.php # Not-found error page
│ ├── error_500.php # Fatal-error page (self-contained, no app-state deps)
│ ├── layout_footer.php # Shared footer (notification polling, boot sequence)
│ ├── layout_header.php # Shared header (nav, command palette, theme toggle)
│ └── TicketView.php # Ticket view with timeline, SLA, watcher avatars
@@ -447,6 +457,21 @@ APP_DOMAIN=your.domain.example
TIMEZONE=America/New_York
```
**⚠️ REQUIRED FOR PRODUCTION — `TRUSTED_PROXIES`:** This app trusts Authelia
forward-auth headers (`Remote-User`, `Remote-Groups`, etc.) to identify who's
logged in. `TRUSTED_PROXIES` restricts that trust to requests that actually
came through your reverse proxy — **leaving it empty disables that check
entirely**, and anyone who can reach the PHP backend directly (a
misconfigured firewall rule, an exposed container port, SSRF from another
internal service) can set `Remote-User: admin` themselves and fully
impersonate any user with zero authentication. Set it to your reverse proxy's
IP address(es) before deploying anywhere reachable beyond your own machine:
```env
TRUSTED_PROXIES=10.10.10.27
```
`GET /api/health.php` reports a `warning` on the `trusted_proxies` check if
this is left empty, so it doesn't go unnoticed after deployment.
Matrix notification variables (all optional):
```env
# hookshot generic webhook URL — send events to Matrix room
+2 -2
View File
@@ -1,8 +1,8 @@
<?php
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -10,8 +10,8 @@
* // $conn, $currentUser, $userId, $isAdmin are now available
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+4 -1
View File
@@ -1,5 +1,8 @@
<?php
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
@@ -25,7 +28,7 @@ if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
exit;
}
}
+3 -3
View File
@@ -5,8 +5,8 @@
* Creates a copy of an existing ticket with the same properties
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
header('Content-Type: application/json');
@@ -34,7 +34,7 @@ try {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
exit;
}
+3 -3
View File
@@ -5,8 +5,8 @@
* CRUD operations for custom field definitions
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
@@ -40,7 +40,7 @@ try {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
exit;
}
}
+3 -3
View File
@@ -7,8 +7,8 @@
*/
// Capture errors for debugging
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -48,7 +48,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Verify CSRF token
$csrfToken = $input['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ResponseHelper::forbidden('Invalid CSRF token');
ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]);
}
// Get attachment ID
+3 -3
View File
@@ -5,8 +5,8 @@
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -49,7 +49,7 @@ 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;
}
+3
View File
@@ -6,6 +6,9 @@
* Serves file downloads for ticket attachments
*/
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -8,8 +8,8 @@
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+10 -3
View File
@@ -1,8 +1,8 @@
<?php
// API endpoint for generating API keys (Admin only)
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -39,8 +39,15 @@ try {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ob_end_clean();
http_response_code(403);
throw new Exception("Invalid CSRF token");
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'Invalid CSRF token',
'csrf_token' => CsrfMiddleware::getToken()
]);
exit;
}
}
+18
View File
@@ -11,6 +11,9 @@
* - 503 Service Unavailable: System has issues
*/
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Don't apply rate limiting to health checks - they should always respond
header('Content-Type: application/json');
header('Cache-Control: no-cache, no-store, must-revalidate');
@@ -162,6 +165,21 @@ if ($maxExecTime === 0 || $maxExecTime >= $requirements['min_max_execution_time'
];
}
// Check 8: TRUSTED_PROXIES configured. Empty disables enforceTrustedProxy()'s
// allowlist entirely, meaning anything that can reach this app directly can
// spoof the Authelia forward-auth Remote-* headers and impersonate any user,
// including an admin. Not fatal (a fresh/dev install may not sit behind a
// proxy yet), but should never go unnoticed on a real deployment.
if (!empty($GLOBALS['config']['TRUSTED_PROXIES'] ?? [])) {
$checks['trusted_proxies'] = ['status' => 'ok', 'message' => 'configured'];
} else {
$checks['trusted_proxies'] = [
'status' => 'warning',
'message' => 'TRUSTED_PROXIES is empty — forward-auth headers are NOT verified; '
. 'anything that can reach this app directly can impersonate any user'
];
}
// Calculate response time
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
+3 -3
View File
@@ -5,8 +5,8 @@
* CRUD operations for recurring_tickets table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
@@ -42,7 +42,7 @@ try {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
exit;
}
}
+3 -3
View File
@@ -5,8 +5,8 @@
* CRUD operations for ticket_templates table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
@@ -39,7 +39,7 @@ try {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
exit;
}
}
+52 -5
View File
@@ -5,8 +5,8 @@
* CRUD operations for status_transitions table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
@@ -40,7 +40,7 @@ try {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
exit;
}
}
@@ -97,13 +97,39 @@ try {
exit;
}
$wf_active = (int)($data['is_active'] ?? 1);
// status_transitions already has a DB-level UNIQUE KEY on
// (from_status, to_status) (regardless of is_active), so a
// duplicate pair can't actually be inserted — but hitting that
// constraint raw surfaces as an opaque "internal error occurred"
// to the admin instead of a clear message. Check first so the
// common case (an admin re-adding a pair that already exists)
// gets a friendly, specific error.
$dupCheck = $conn->prepare(
"SELECT transition_id FROM status_transitions WHERE from_status = ? AND to_status = ?"
);
$dupCheck->bind_param('ss', $data['from_status'], $data['to_status']);
$dupCheck->execute();
if ($dupCheck->get_result()->fetch_assoc()) {
$dupCheck->close();
http_response_code(409);
echo json_encode([
'success' => false,
'error' => 'A transition already exists for '
. $data['from_status'] . ' → ' . $data['to_status']
. ' — edit that row instead of creating a duplicate.',
]);
exit;
}
$dupCheck->close();
$stmt = $conn->prepare("INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin, is_active)
VALUES (?, ?, ?, ?, ?)");
$wf_from = $data['from_status'];
$wf_to = $data['to_status'];
$wf_comment = (int)($data['requires_comment'] ?? 0);
$wf_admin = (int)($data['requires_admin'] ?? 0);
$wf_active = (int)($data['is_active'] ?? 1);
$stmt->bind_param('ssiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active);
if ($stmt->execute()) {
@@ -149,6 +175,28 @@ try {
exit;
}
$wf_active = (int)($data['is_active'] ?? 1);
// Same duplicate-pair guard as create, excluding this row itself.
$dupCheck = $conn->prepare(
"SELECT transition_id FROM status_transitions
WHERE from_status = ? AND to_status = ? AND transition_id != ?"
);
$dupCheck->bind_param('ssi', $data['from_status'], $data['to_status'], $id);
$dupCheck->execute();
if ($dupCheck->get_result()->fetch_assoc()) {
$dupCheck->close();
http_response_code(409);
echo json_encode([
'success' => false,
'error' => 'A transition already exists for '
. $data['from_status'] . ' → ' . $data['to_status']
. ' — edit that row instead of creating a duplicate.',
]);
exit;
}
$dupCheck->close();
$stmt = $conn->prepare("UPDATE status_transitions SET
from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ?
WHERE transition_id = ?");
@@ -156,7 +204,6 @@ try {
$wf_to = $data['to_status'];
$wf_comment = (int)($data['requires_comment'] ?? 0);
$wf_admin = (int)($data['requires_admin'] ?? 0);
$wf_active = (int)($data['is_active'] ?? 1);
$stmt->bind_param('ssiiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active, $id);
$success = $stmt->execute();
+40 -1
View File
@@ -15,8 +15,10 @@
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
$prefsModel = new UserPreferencesModel($conn);
$ticketModel = new TicketModel($conn);
// ── POST: mark all read (update last_seen timestamp) ──────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
@@ -204,7 +206,44 @@ foreach (array_merge($assignRows, $commentRows, $statusRows, $mentionRows) as $r
$all[] = $row;
}
usort($all, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
$all = array_slice($all, 0, 30);
// Re-check current ticket visibility before surfacing anything: a
// notification's audit_log entry reflects historical activity, but the
// ticket's visibility (or the user's group/watcher standing) may have
// tightened since. Without this, a notification still discloses the
// ticket's title and that activity occurred to someone who currently
// shouldn't see it, even though the ticket view's own access check would
// correctly reject them from opening it.
$candidateTicketIds = [];
foreach ($all as $row) {
$details = json_decode($row['details'] ?? '{}', true) ?? [];
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
? 'comment'
: $row['action_type'];
$tid = ($actionType === 'comment' || $actionType === 'mention')
? ($details['ticket_id'] ?? 0)
: $row['entity_id'];
if ($tid) {
$candidateTicketIds[(string)$tid] = true;
}
}
$ticketsById = !empty($candidateTicketIds)
? $ticketModel->getTicketsByIds(array_keys($candidateTicketIds))
: [];
$all = array_filter($all, function ($row) use ($ticketsById, $currentUser, $ticketModel) {
$details = json_decode($row['details'] ?? '{}', true) ?? [];
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
? 'comment'
: $row['action_type'];
$tid = (string)(($actionType === 'comment' || $actionType === 'mention')
? ($details['ticket_id'] ?? 0)
: $row['entity_id']);
$ticket = $ticketsById[$tid] ?? null;
return $ticket && $ticketModel->canUserAccessTicket($ticket, $currentUser);
});
$all = array_slice(array_values($all), 0, 30);
// Format for response
$notifications = [];
+10 -3
View File
@@ -1,8 +1,8 @@
<?php
// API endpoint for revoking API keys (Admin only)
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -39,8 +39,15 @@ try {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ob_end_clean();
http_response_code(403);
throw new Exception("Invalid CSRF token");
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'Invalid CSRF token',
'csrf_token' => CsrfMiddleware::getToken()
]);
exit;
}
}
+2 -2
View File
@@ -18,8 +18,8 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+87
View File
@@ -0,0 +1,87 @@
<?php
/**
* Save custom field values for a ticket.
*
* POST { ticket_id, values: { [field_id]: value, ... } }
*
* Only fields applicable to the ticket's current category (or category-less
* fields) are considered; anything else in `values` is ignored rather than
* persisted, so a value typed for a field that no longer applies (e.g. the
* category changed) can't linger as orphaned/misleading data.
*/
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
apiRespond(['success' => false, 'error' => 'Method not allowed']);
}
$data = json_decode(file_get_contents('php://input'), true);
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
$values = is_array($data['values'] ?? null) ? $data['values'] : [];
if ($ticketId === '') {
http_response_code(400);
apiRespond(['success' => false, 'error' => 'ticket_id required']);
}
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
apiRespond(['success' => false, 'error' => 'Ticket not found']);
}
$fieldModel = new CustomFieldModel($conn);
$definitions = $fieldModel->getAllDefinitions($ticket['category'], true);
$errors = [];
$toSave = [];
foreach ($definitions as $def) {
$fieldId = (int)$def['field_id'];
$raw = $values[$fieldId] ?? ($values[(string)$fieldId] ?? null);
if ($def['field_type'] === 'checkbox') {
$normalized = !empty($raw) ? '1' : '0';
} else {
$normalized = is_scalar($raw) ? trim((string)$raw) : '';
}
if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
$errors[] = $def['field_label'] . ' is required';
continue;
}
if ($def['field_type'] === 'select' && $normalized !== '') {
$allowedOptions = $def['field_options']['options'] ?? [];
if (!in_array($normalized, $allowedOptions, true)) {
$errors[] = $def['field_label'] . ' has an invalid selection';
continue;
}
}
if ($def['field_type'] === 'number' && $normalized !== '' && !is_numeric($normalized)) {
$errors[] = $def['field_label'] . ' must be a number';
continue;
}
$toSave[$fieldId] = $normalized;
}
if (!empty($errors)) {
http_response_code(422);
apiRespond(['success' => false, 'error' => implode('; ', $errors)]);
}
$fieldModel->setValues($ticketId, $toSave);
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
(new AuditLogModel($conn))->log($userId, 'update', 'ticket', $ticketId, [
'reason' => 'custom fields updated',
]);
apiRespond(['success' => true]);
+1 -1
View File
@@ -98,7 +98,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DEL
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ResponseHelper::forbidden('Invalid CSRF token');
ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]);
}
}
+2 -2
View File
@@ -22,8 +22,8 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -14,8 +14,8 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Rate limiting (same pattern as the other Bearer API endpoints)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+6 -3
View File
@@ -5,8 +5,8 @@
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -104,7 +104,10 @@ try {
'update',
'comment',
(string)$commentId,
['comment_text_preview' => substr($commentText, 0, 100)]
[
'ticket_id' => $comment['ticket_id'] ?? null,
'comment_text_preview' => substr($commentText, 0, 100),
]
);
}
+4 -3
View File
@@ -1,8 +1,8 @@
<?php
// Enable error reporting for debugging
error_reporting(E_ALL);
ini_set('display_errors', 0); // Don't display errors in the response
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -276,7 +276,8 @@ try {
$updateData['title'],
'status_changed',
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
(int)$this->userId
(int)$this->userId,
$currentTicket['visibility'] ?? 'public'
);
}
+3 -3
View File
@@ -7,8 +7,8 @@
*/
// Capture errors for debugging
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -155,7 +155,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
// Verify CSRF token
$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ResponseHelper::forbidden('Invalid CSRF token');
ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]);
}
// Get ticket ID
+2 -2
View File
@@ -11,8 +11,8 @@
* Returns 404 if the user has no avatar set in lldap.
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+28 -7
View File
@@ -61,25 +61,46 @@ function populateCurrentFilters() {
const urlParams = new URLSearchParams(window.location.search);
// Search text
if (urlParams.has('search')) {
document.getElementById('adv-search-text').value = urlParams.get('search');
}
document.getElementById('adv-search-text').value = urlParams.get('search') || '';
// Status
if (urlParams.has('status')) {
const statuses = urlParams.get('status').split(',');
const statuses = urlParams.has('status') ? urlParams.get('status').split(',') : [];
const statusSelect = document.getElementById('adv-status');
Array.from(statusSelect.options).forEach(option => {
option.selected = statuses.includes(option.value);
});
}
// Date ranges
document.getElementById('adv-created-from').value = urlParams.get('created_from') || '';
document.getElementById('adv-created-to').value = urlParams.get('created_to') || '';
document.getElementById('adv-updated-from').value = urlParams.get('updated_from') || '';
document.getElementById('adv-updated-to').value = urlParams.get('updated_to') || '';
// Priority range
document.getElementById('adv-priority-min').value = urlParams.get('priority_min') || '';
document.getElementById('adv-priority-max').value = urlParams.get('priority_max') || '';
// Users
document.getElementById('adv-created-by').value = urlParams.get('created_by') || '';
document.getElementById('adv-assigned-to').value = urlParams.get('assigned_to') || '';
}
// Perform advanced search
function performAdvancedSearch(event) {
event.preventDefault();
const params = new URLSearchParams();
// Start from the CURRENT URL's params, not a fresh set, so a filter this
// form doesn't represent (e.g. a category/type filter applied via a
// dashboard quick-filter pill or stats-widget click) isn't silently
// dropped on submit. Only the params this form actually controls are
// set/cleared below; everything else passes through untouched.
const params = new URLSearchParams(window.location.search);
const advParams = [
'search', 'created_from', 'created_to', 'updated_from', 'updated_to',
'status', 'priority_min', 'priority_max', 'created_by', 'assigned_to',
];
advParams.forEach(key => params.delete(key));
params.delete('page'); // filters changed — reset to page 1
// Search text
const searchText = document.getElementById('adv-search-text').value.trim();
+21 -70
View File
@@ -41,6 +41,16 @@
* 32. Drag & Drop Upload
* 33. Intersection Observer
* 34. Full Initialisation
*
* NOTE ON EMPTY CATCH BLOCKS: throughout this file, `try { ... } catch (_) {}`
* around localStorage/sessionStorage access (persisted tab/theme/column-
* visibility state, recent command-palette entries, etc.) and the terminal
* beep's AudioContext calls is intentional, not an oversight these are
* best-effort UX affordances that must silently no-op rather than break the
* surrounding feature if storage is disabled/full (private browsing, quota)
* or audio is blocked (autoplay policy). Swallowing errors from arbitrary
* caller-supplied callbacks (e.g. viewport-change listeners) is handled
* separately with real logging, since those can hide genuine bugs.
*/
(function (global) {
@@ -1398,7 +1408,7 @@
_vpCurrent = bp;
if (bp !== prev) {
const evt = { bp, w, h, prev };
_vpListeners.forEach(cb => { try { cb(evt); } catch (_) {} });
_vpListeners.forEach(cb => { try { cb(evt); } catch (e) { console.error('[lt.viewport] listener threw:', e); } });
bus.emit('viewport:change', evt);
}
}
@@ -2801,7 +2811,7 @@
};
// Patch lt.api — auth-aware wrapper (renamed to avoid strict-mode duplicate declaration)
async function _apiFetchAuth(method, url, body) {
async function _apiFetchAuth(method, url, body, retried) {
if (_authAccess && auth.isExpiringSoon()) await auth.refresh();
const opts = { method, headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()) };
if (_authAccess) opts.headers['Authorization'] = 'Bearer ' + _authAccess;
@@ -2821,6 +2831,15 @@
// 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;
// Auto-retry once on a stale-CSRF-token 403: the token lifetime (1h) is
// shorter than the session idle timeout (5h), so this is a routine,
// recoverable case (an hour of inactivity, or a write in another tab
// rotating the shared token) rather than a real rejection — resyncing
// above already has the fresh token, so silently resending once succeeds
// transparently instead of surfacing a confusing error on the first try.
if (resp.status === 403 && !retried && data && data.csrf_token) {
return _apiFetchAuth(method, url, body, true);
}
if (!resp.ok) {
const err = new Error(data.error || data.message || 'HTTP ' + resp.status);
err.data = data;
@@ -2911,73 +2930,6 @@
},
};
/* ================================================================
MODULE 54 MARKDOWN RENDERER
lt.markdown.render(mdString) HTML string (sanitized)
lt.markdown.init(selector) renders all matching el's .textContent
Uses a built-in micro-renderer (no deps) for common syntax.
For full GFM, swap in marked.js: window.marked && marked.parse()
================================================================ */
const markdown = {
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
.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => `<pre class="lt-code-block"><code class="lt-tok tok-${lang || 'plain'}">${code.trim()}</code></pre>`)
// Inline code
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Headings
.replace(/^######\s(.+)$/gm, '<h6>$1</h6>')
.replace(/^#####\s(.+)$/gm, '<h5>$1</h5>')
.replace(/^####\s(.+)$/gm, '<h4>$1</h4>')
.replace(/^###\s(.+)$/gm, '<h3>$1</h3>')
.replace(/^##\s(.+)$/gm, '<h2>$1</h2>')
.replace(/^#\s(.+)$/gm, '<h1>$1</h1>')
// Bold / italic
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
.replace(/_(.+?)_/g, '<em>$1</em>')
// Links — block javascript: and data: URIs
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => {
const safeUrl = /^(https?:\/\/|\/|#|\.\.?\/)/i.test(url) ? url : '#';
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${escHtml(text)}</a>`;
})
// Images — block javascript: and data: URIs
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => {
const safeSrc = /^(https?:\/\/|\/|\.\.?\/)/i.test(src) ? src : '';
return `<img src="${safeSrc}" alt="${escHtml(alt)}" style="max-width:100%">`;
})
// Blockquote
.replace(/^&gt;\s(.+)$/gm, '<blockquote>$1</blockquote>')
// Horizontal rule
.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '<hr>')
// Unordered list items
.replace(/^[-*+]\s(.+)$/gm, '<li>$1</li>')
.replace(/(<li>[\s\S]+?<\/li>\n?)+/g, m => `<ul>${m}</ul>`)
// Ordered list items
.replace(/^\d+\.\s(.+)$/gm, '<li>$1</li>')
// Paragraphs (double newline)
.replace(/\n{2,}/g, '</p><p>')
.replace(/\n/g, '<br>');
return `<p>${html}</p>`
.replace(/<p>(<(?:pre|ul|ol|h[1-6]|blockquote|hr)[^>]*>)/g, '$1')
.replace(/(<\/(?:pre|ul|ol|h[1-6]|blockquote|hr)>)<\/p>/g, '$1');
},
init(selector) {
document.querySelectorAll(selector).forEach(el => {
const raw = el.getAttribute('data-markdown') || el.textContent;
el.innerHTML = markdown.render(raw);
el.classList.add('lt-markdown');
});
},
};
/* ================================================================
MODULE 55 PAGINATION
lt.pagination.init(navEl, opts)
@@ -3140,7 +3092,6 @@
timer,
lightbox,
auth,
markdown,
ticketStatus,
pagination,
sidebarSubmenus: { init: initSidebarSubmenus },
+22
View File
@@ -506,6 +506,25 @@ function toolbarHeading(textareaId) {
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
function toolbarTable(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const text = textarea.value;
// Insert on its own line(s), matching the blank-line-before convention
// toolbarList/toolbarHeading rely on the surrounding text for — a table
// needs a full line to itself both before and after the separator row.
const needsLeadingNewline = start > 0 && text[start - 1] !== '\n';
const template = (needsLeadingNewline ? '\n' : '')
+ '| Header 1 | Header 2 |\n'
+ '| --- | --- |\n'
+ '| Cell 1 | Cell 2 |\n';
insertMarkdownText(textareaId, template);
}
function toolbarQuote(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
@@ -544,6 +563,7 @@ function createEditorToolbar(textareaId, containerId) {
<button type="button" data-toolbar-action="heading" data-textarea="${textareaId}" title="Heading">H</button>
<button type="button" data-toolbar-action="list" data-textarea="${textareaId}" title="List"></button>
<button type="button" data-toolbar-action="quote" data-textarea="${textareaId}" title="Quote">"</button>
<button type="button" data-toolbar-action="table" data-textarea="${textareaId}" title="Table"></button>
<span class="toolbar-separator"></span>
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">[ @ ]</button>
`;
@@ -563,6 +583,7 @@ function createEditorToolbar(textareaId, containerId) {
case 'heading': toolbarHeading(targetId); break;
case 'list': toolbarList(targetId); break;
case 'quote': toolbarQuote(targetId); break;
case 'table': toolbarTable(targetId); break;
case 'link': toolbarLink(targetId); break;
}
});
@@ -578,6 +599,7 @@ window.toolbarLink = toolbarLink;
window.toolbarList = toolbarList;
window.toolbarHeading = toolbarHeading;
window.toolbarQuote = toolbarQuote;
window.toolbarTable = toolbarTable;
window.createEditorToolbar = createEditorToolbar;
window.insertMarkdownFormat = insertMarkdownFormat;
window.insertMarkdownText = insertMarkdownText;
+7 -2
View File
@@ -357,12 +357,17 @@ function togglePreview() {
if (isPreviewEnabled) {
preview.innerHTML = parseMarkdown(textarea.value);
textarea.addEventListener('input', updatePreview);
textarea.addEventListener('input', debouncedUpdatePreview);
} else {
textarea.removeEventListener('input', updatePreview);
textarea.removeEventListener('input', debouncedUpdatePreview);
}
}
// Re-running the full markdown parser on every single keystroke is wasted
// work while the user is still mid-word; 150ms debounce keeps the preview
// feeling live without re-parsing on every keystroke.
const debouncedUpdatePreview = window.lt ? lt.debounce(updatePreview, 150) : updatePreview;
function updatePreview() {
const textarea = document.getElementById('newComment');
const previewDiv = document.getElementById('markdownPreview');
+8 -3
View File
@@ -106,6 +106,11 @@ $GLOBALS['config'] = [
'SESSION_TIMEOUT' => 18000, // 5 hours in seconds
'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes
// How often an already-logged-in session re-validates Remote-User/
// Remote-Groups against current Authelia/LLDAP state (AuthMiddleware).
// Without this, a revoked admin keeps full access for up to SESSION_TIMEOUT.
'PRIVILEGE_RESYNC_INTERVAL' => 300, // 5 minutes
// CSRF settings
'CSRF_LIFETIME' => 3600, // 1 hour in seconds
@@ -136,9 +141,9 @@ $GLOBALS['config'] = [
],
'UPLOAD_DIR' => __DIR__ . '/../uploads',
// Rate limiting
'RATE_LIMIT_DEFAULT' => 100, // Requests per minute for general
'RATE_LIMIT_API' => 60, // Requests per minute for API
// Rate limiting (requests per minute; read by RateLimitMiddleware)
'RATE_LIMIT_DEFAULT' => (int)($envVars['RATE_LIMIT_DEFAULT'] ?? 100), // Session-based, general endpoints
'RATE_LIMIT_API' => (int)($envVars['RATE_LIMIT_API'] ?? 60), // Session-based, API endpoints
// Audit log settings
'AUDIT_LOG_RETENTION_DAYS' => 90,
+52
View File
@@ -7,6 +7,7 @@ require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
require_once dirname(__DIR__) . '/models/TemplateModel.php';
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
@@ -18,6 +19,7 @@ class TicketController
private $userModel;
private $workflowModel;
private $templateModel;
private $customFieldModel;
private $conn;
public function __construct($conn)
@@ -29,6 +31,7 @@ class TicketController
$this->userModel = new UserModel($conn);
$this->workflowModel = new WorkflowModel($conn);
$this->templateModel = new TemplateModel($conn);
$this->customFieldModel = new CustomFieldModel($conn);
}
public function view($id)
@@ -60,6 +63,11 @@ class TicketController
// Get allowed status transitions for this ticket
$allowedTransitions = $this->workflowModel->getAllowedTransitions($ticket['status']);
// Custom fields applicable to this ticket's category, with any
// already-saved values for it
$customFieldDefs = $this->customFieldModel->getAllDefinitions($ticket['category'], true);
$customFieldValues = $this->customFieldModel->getValuesForTicket($id);
// Make $conn available to view for visibility groups
$conn = $this->conn;
@@ -73,6 +81,12 @@ class TicketController
$currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null;
// All active custom field definitions (every category, plus
// category-less ones) — the create form renders them all and toggles
// visibility client-side as the Category select changes, since the
// ticket doesn't exist yet to scope the query to one category.
$allCustomFieldDefs = $this->customFieldModel->getAllDefinitions(null, true);
// Check if form was submitted
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validate CSRF token
@@ -131,6 +145,38 @@ class TicketController
return;
}
// Custom fields applicable to the submitted category — validate
// is_required server-side (the form is novalidate, and a field
// hidden by the client-side category toggle must not silently
// bypass a requirement that applies to the category actually
// submitted).
$submittedCustomFields = is_array($_POST['custom_fields'] ?? null) ? $_POST['custom_fields'] : [];
$applicableFieldDefs = array_filter(
$allCustomFieldDefs,
fn($def) => $def['category'] === null || $def['category'] === $ticketData['category']
);
$customFieldsToSave = [];
foreach ($applicableFieldDefs as $def) {
$fieldId = (int)$def['field_id'];
$raw = $submittedCustomFields[$fieldId] ?? null;
$normalized = $def['field_type'] === 'checkbox'
? (!empty($raw) ? '1' : '0')
: (is_scalar($raw) ? trim((string)$raw) : '');
if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
$error = $def['field_label'] . ' is required';
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn;
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
if ($normalized !== '') {
$customFieldsToSave[$fieldId] = $normalized;
}
}
// Create ticket with user tracking
$result = $this->ticketModel->createTicket($ticketData, $userId);
@@ -144,6 +190,12 @@ class TicketController
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($this->conn))->invalidateCache();
// Persist custom field values for the fields applicable to
// this ticket's category
if (!empty($customFieldsToSave)) {
$this->customFieldModel->setValues($result['ticket_id'], $customFieldsToSave);
}
// Auto-link as duplicate if requested from create form
$linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
+78 -53
View File
@@ -2,15 +2,16 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once __DIR__ . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once __DIR__ . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
// Load environment variables with error check
$envFile = __DIR__ . '/.env';
if (!file_exists($envFile)) {
// Early friendly JSON error if .env is missing, before config.php's own
// (plain-text die()) handling would otherwise run — this is a JSON API
// endpoint and must always respond with a JSON body.
if (!file_exists(__DIR__ . '/.env')) {
echo json_encode([
'success' => false,
'error' => 'Configuration file not found'
@@ -18,37 +19,17 @@ if (!file_exists($envFile)) {
exit;
}
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
if (!$envVars) {
echo json_encode([
'success' => false,
'error' => 'Invalid configuration file'
]);
exit;
}
// Load application config so UrlHelper can resolve APP_DOMAIN, and so the
// DB connection below (via Database::getConnection()) gets the same
// charset/timezone sync as every other endpoint instead of a hand-rolled
// second connection.
require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/helpers/Database.php';
// Strip quotes from values if present (parse_ini_file may include them)
foreach ($envVars as $key => $value) {
if (is_string($value)) {
if (
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
) {
$envVars[$key] = substr($value, 1, -1);
}
}
}
// Database connection with detailed error handling
$conn = new mysqli(
$envVars['DB_HOST'],
$envVars['DB_USER'],
$envVars['DB_PASS'],
$envVars['DB_NAME']
);
if ($conn->connect_error) {
error_log('create_ticket_api: DB connection failed: ' . $conn->connect_error);
try {
$conn = Database::getConnection();
} catch (\Throwable $e) {
error_log('create_ticket_api: DB connection failed: ' . $e->getMessage());
http_response_code(500);
echo json_encode([
'success' => false,
@@ -57,13 +38,12 @@ if ($conn->connect_error) {
exit;
}
// Load application config so UrlHelper can resolve APP_DOMAIN
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__ . '/models/TicketModel.php';
require_once __DIR__ . '/models/WorkflowModel.php';
require_once __DIR__ . '/helpers/UrlHelper.php';
$apiKeyAuth = new ApiKeyAuth($conn);
@@ -349,7 +329,7 @@ if ($existing) {
(new StatsModel($conn))->invalidateCache();
}
$conn->close();
Database::close();
echo json_encode([
'success' => true,
'ticket_id' => $existingId,
@@ -360,17 +340,52 @@ if ($existing) {
exit;
}
// Ticket was closed — reopen it and add a recurrence comment
$reopenStmt = $conn->prepare(
"UPDATE tickets SET status = 'Open', closed_at = NULL, updated_at = NOW(), updated_by = ? WHERE ticket_id = ?"
);
$reopenStmt->bind_param("is", $userId, $existingId);
$reopenStmt->execute();
$reopenStmt->close();
// Ticket was closed — reopen it and add a recurrence comment. Route
// through the Workflow Designer like every other status-write path in
// the app, rather than forcing status='Open' via raw SQL regardless of
// configured transition rules.
$workflowModel = new WorkflowModel($conn);
$reopenStatus = 'Open';
if (!$workflowModel->isTransitionAllowed('Closed', 'Open', false)) {
// Direct Closed->Open isn't configured — fall back to any transition
// the Workflow Designer does allow from Closed that this unattended,
// non-admin automation can actually satisfy (no comment prompt, no
// admin elevation). If even that doesn't exist, leave the ticket
// Closed rather than force an unconfigured state.
$reopenStatus = null;
foreach ($workflowModel->getAllowedTransitions('Closed') as $transition) {
if (!$transition['requires_comment'] && !$transition['requires_admin']) {
$reopenStatus = $transition['to_status'];
break;
}
}
}
if ($reopenStatus !== null) {
$ticketModel = new TicketModel($conn);
$ticketModel->updateTicket([
'ticket_id' => $existingId,
'title' => $title,
'description' => $description,
'category' => $category,
'type' => $type,
'status' => $reopenStatus,
'priority' => $priority,
], $userId);
} else {
error_log("create_ticket_api: hwmonDaemon recurrence for ticket $existingId"
. "no admin-free, comment-free transition from Closed is configured; leaving ticket Closed");
}
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
"hwmonDaemon detected this condition again. The ticket description reflects the "
. "original report; see this comment's timestamp for when the issue recurred.";
if ($reopenStatus === null) {
$commentText = "**Issue recurred, but the ticket could not be reopened automatically.**\n\n"
. "hwmonDaemon detected this condition again. No Workflow Designer transition from "
. "Closed is configured that this automation can perform unattended (no comment/admin "
. "requirement); the ticket remains Closed. Please review and reopen manually if appropriate.";
}
$commentStmt = $conn->prepare(
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
);
@@ -378,30 +393,40 @@ if ($existing) {
$commentStmt->execute();
$commentStmt->close();
if ($reopenStatus !== null) {
$auditLog->log($userId, 'update', 'ticket', $existingId, [
'status' => ['from' => 'Closed', 'to' => 'Open'],
'status' => ['from' => 'Closed', 'to' => $reopenStatus],
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
]);
// Ticket reopened (Closed → Open) — refresh dashboard stats.
// Ticket reopened — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
} else {
$auditLog->log($userId, 'update', 'ticket', $existingId, [
'reason' => 'hwmonDaemon recurrence detected but no valid reopen transition configured; ticket left Closed',
]);
}
$conn->close();
Database::close();
if ($reopenStatus !== null) {
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($existingId, [
'title' => $title,
'priority' => $priority,
'category' => $category,
'type' => $type,
'status' => 'Open',
'status' => $reopenStatus,
], 'automated');
}
echo json_encode([
'success' => true,
'ticket_id' => $existingId,
'message' => 'Existing closed ticket reopened',
'action' => 'reopened',
'message' => $reopenStatus !== null
? 'Existing closed ticket reopened'
: 'Recurrence noted; ticket left Closed (no valid workflow transition configured)',
'action' => $reopenStatus !== null ? 'reopened' : 'recurrence_noted',
]);
exit;
}
@@ -484,7 +509,7 @@ if ($inserted) {
// New ticket created — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
$conn->close();
Database::close();
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($ticket_id, [
+39 -1
View File
@@ -29,6 +29,38 @@ function logMessage($message)
echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n";
}
/**
* Record a recurring-ticket occurrence that was claimed (next_run_at already
* advanced to the next future run) but then failed to actually produce a
* ticket. That claim-then-fail ordering is deliberate it stops a failing
* creation from re-firing and flooding duplicates on every subsequent cron
* tick but means this specific occurrence has no other record anywhere an
* admin would normally look: no audit_log entry (nothing was created), no
* Matrix "ticket created" alert, no failure table. Without this, it's simply
* gone, silently, forever.
*/
function recordMissedOccurrence($auditLog, $recurring, $reason)
{
$auditLog->log(
$recurring['created_by'],
'error',
'recurring_ticket',
(string)$recurring['recurring_id'],
[
'reason' => $reason,
'title_template' => $recurring['title_template'],
'schedule_type' => $recurring['schedule_type'],
]
);
NotificationHelper::sendSystemAlert(
"Recurring ticket occurrence lost: schedule #{$recurring['recurring_id']} "
. "(\"{$recurring['title_template']}\") was claimed for this run but ticket "
. "creation failed, so this occurrence will not be created or retried.",
['reason' => $reason, 'recurring_id' => $recurring['recurring_id']]
);
}
logMessage("Starting recurring tickets cron job");
try {
@@ -100,11 +132,17 @@ try {
$created++;
} else {
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
$reason = $result['error'] ?? 'Unknown error';
logMessage("ERROR: Failed to create ticket - " . $reason);
recordMissedOccurrence($auditLog, $recurring, $reason);
$errors++;
}
} catch (Exception $e) {
logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage());
// claimForRun() already advanced next_run_at before this point, so
// this occurrence is permanently gone unless recorded somewhere an
// admin would actually look — a cron log line alone doesn't count.
recordMissedOccurrence($auditLog, $recurring, $e->getMessage());
$errors++;
}
}
+25 -4
View File
@@ -10,26 +10,36 @@ class ErrorHandler
{
private static ?string $logFile = null;
private static bool $initialized = false;
private static string $responseMode = 'json';
/**
* Initialize error handling
*
* @param bool $displayErrors Whether to display errors (false in production)
* @param string $responseMode 'json' (API endpoints) or 'html' (page views
* renders views/error_500.php instead of a JSON body)
*/
public static function init(bool $displayErrors = false): void
public static function init(bool $displayErrors = false, string $responseMode = 'json'): void
{
if (self::$initialized) {
return;
}
self::$responseMode = $responseMode;
// Set error reporting
error_reporting(E_ALL);
ini_set('display_errors', $displayErrors ? '1' : '0');
ini_set('log_errors', '1');
// Set up log file
self::$logFile = sys_get_temp_dir() . '/tinker_tickets_errors.log';
ini_set('error_log', self::$logFile);
// Deliberately does NOT override the 'error_log' ini setting: doing so
// used to redirect every error_log() call in the request to a fixed
// /tmp file, silently diverting logs away from wherever the server is
// actually configured to send them (php-fpm's error_log, stdout in a
// container, etc.) the moment this got wired into more than one
// endpoint. self::$logFile / getRecentErrors() are unused (no callers
// app-wide) and exist only as an opt-in helper if something later
// wants a dedicated log file.
// Register handlers
set_error_handler([self::class, 'handleError']);
@@ -151,6 +161,17 @@ class ErrorHandler
{
http_response_code($httpCode);
if (self::$responseMode === 'html') {
if (!headers_sent()) {
header('Content-Type: text/html; charset=utf-8');
}
// Deliberately not passed $message/$exception — see error_500.php's
// docblock on why the fatal-error page must render with zero
// dependency on request-specific state.
include dirname(__DIR__) . '/views/error_500.php';
exit;
}
if (!headers_sent()) {
header('Content-Type: application/json');
}
+23
View File
@@ -20,6 +20,12 @@ class NotificationHelper
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
// A slow-but-not-fully-hung hookshot endpoint could otherwise add up
// to the full CURLOPT_TIMEOUT per fire() call, and a single request
// can call fire() (via notifyWatchers/sendCommentNotification/etc.)
// more than once sequentially — capping just the connect phase keeps
// that from stacking into tens of seconds of added latency.
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
@@ -53,6 +59,23 @@ class NotificationHelper
// ─── Public event methods ─────────────────────────────────────────────────
/**
* Generic operational alert with no associated ticket (e.g. a recurring
* schedule whose ticket creation failed after its next_run_at was
* already advanced, so the missed occurrence has no other record an
* admin would normally see). Always sent to the shared
* MATRIX_NOTIFY_USERS list, regardless of any per-event notify toggle.
*/
public static function sendSystemAlert(string $message, array $context = []): void
{
self::fire(array_merge([
'event' => 'system_alert',
'message' => $message,
], $context, [
'notify_users' => self::notifyUsers(),
]));
}
/**
* New ticket created (manual or automated/API).
*
+15 -10
View File
@@ -1,10 +1,18 @@
<?php
// Main entry point for the application
// Registered first, before anything else, so a genuine fatal anywhere below
// (including during config.php's own env parsing) renders the styled 500
// page instead of falling through to PHP's raw default error handling.
require_once 'helpers/ErrorHandler.php';
ErrorHandler::init(false, 'html');
require_once 'config/config.php';
require_once 'middleware/SecurityHeadersMiddleware.php';
require_once 'middleware/AuthMiddleware.php';
require_once 'models/AuditLogModel.php';
require_once 'helpers/Database.php';
// Apply security headers early
SecurityHeadersMiddleware::apply();
@@ -17,15 +25,12 @@ $requestPath = strtok($request, '?');
// Create database connection for non-API routes
if (!str_starts_with($requestPath, '/api/')) {
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
die("Connection failed: " . $conn->connect_error);
try {
$conn = Database::getConnection();
} catch (\Throwable $e) {
error_log('index.php: database connection failed: ' . $e->getMessage());
http_response_code(500);
die('Sorry, something went wrong. Please try again shortly.');
}
// Authenticate user via Authelia forward auth
@@ -444,5 +449,5 @@ switch (true) {
// Close database connection if it was opened
if (isset($conn)) {
$conn->close();
Database::close();
}
+72
View File
@@ -92,6 +92,19 @@ class AuthMiddleware
} else {
// Update last activity time
$_SESSION['last_activity'] = time();
// Periodically re-validate Remote-User/Remote-Groups against
// current Authelia/LLDAP state, so a revoked admin (or anyone
// dropped from the required groups) loses access promptly
// instead of keeping it for up to SESSION_TIMEOUT. Only the
// idle timer was checked above; nothing previously re-read
// these headers once a session already existed.
$resyncInterval = $GLOBALS['config']['PRIVILEGE_RESYNC_INTERVAL'] ?? 300;
$lastSync = $_SESSION['last_privilege_sync'] ?? 0;
if (time() - $lastSync > $resyncInterval) {
$this->resyncPrivileges();
}
return $_SESSION['user'];
}
}
@@ -134,6 +147,7 @@ class AuthMiddleware
// Store user in session
$_SESSION['user'] = $user;
$_SESSION['last_activity'] = time();
$_SESSION['last_privilege_sync'] = time();
// Generate new CSRF token on login
require_once __DIR__ . '/CsrfMiddleware.php';
@@ -142,6 +156,64 @@ class AuthMiddleware
return $user;
}
/**
* Re-validate the current session's Remote-User/Remote-Groups against
* this request's forward-auth headers, and re-sync or revoke access on
* mismatch. Called periodically (PRIVILEGE_RESYNC_INTERVAL) from an
* already-authenticated session see authenticate().
*
* Best-effort: if this particular request doesn't carry forward-auth
* headers at all (e.g. a proxy hiccup), the session is left as-is rather
* than force-logging the user out, and the check is simply retried on
* the next request past the interval.
*/
private function resyncPrivileges(): void
{
$username = $this->getHeader('HTTP_REMOTE_USER');
$groups = $this->getHeader('HTTP_REMOTE_GROUPS');
if (empty($username)) {
return;
}
$this->enforceTrustedProxy();
// A different Remote-User than the session's own means Authelia is
// now asserting a different identity entirely for this proxy path;
// don't silently relabel the session as that other user.
if ($username !== ($_SESSION['user']['username'] ?? null)) {
return;
}
if (!$this->checkGroupAccess($groups)) {
$this->logSecurityEvent('privilege_resync_revoked', [
'username' => $username,
'groups' => $groups ?: 'none',
]);
session_unset();
session_destroy();
$this->redirectToAuth();
exit;
}
$displayName = $this->getHeader('HTTP_REMOTE_NAME');
$email = $this->getHeader('HTTP_REMOTE_EMAIL');
// Bypass UserModel's 5-minute in-process cache — that cache key isn't
// group-aware, so a stale cached hit here would silently keep serving
// the pre-revocation is_admin value for the rest of the cache's TTL.
UserModel::invalidateCache(null, $username);
$user = $this->userModel->syncUserFromAuthelia($username, $displayName, $email, $groups);
$wasAdmin = !empty($_SESSION['user']['is_admin']);
if ($wasAdmin && empty($user['is_admin'])) {
$this->logSecurityEvent('privilege_resync_admin_revoked', ['username' => $username]);
}
$_SESSION['user'] = $user;
$_SESSION['last_privilege_sync'] = time();
}
/**
* Reject forward-auth headers that did not arrive via a trusted proxy.
*
+128 -20
View File
@@ -3,21 +3,34 @@
/**
* Rate Limiting Middleware
*
* Implements both session-based and IP-based rate limiting to prevent abuse.
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
* Implements session-based, IP-based, and (for Bearer-authenticated
* requests) API-key-based rate limiting to prevent abuse.
* IP-based limiting prevents attackers from bypassing limits by creating new
* sessions; API-key-based limiting keeps distinct Bearer clients from
* starving each other's shared IP bucket.
*/
class RateLimitMiddleware
{
// Default limits
// Fallback limits, used only if $GLOBALS['config'] isn't populated
// (e.g. very early in bootstrap, or a test harness). Normal requests read
// RATE_LIMIT_DEFAULT/RATE_LIMIT_API from config (backed by .env).
public const DEFAULT_LIMIT = 100; // requests per window (session)
public const API_LIMIT = 60; // API requests per window (session)
public const IP_LIMIT = 300; // IP-based requests per window (more generous)
public const IP_API_LIMIT = 120; // IP-based API requests per window
public const API_KEY_LIMIT = 120; // Per-Bearer-token requests per window
public const WINDOW_SECONDS = 60; // 1 minute window
// Directory for IP rate limit storage
private static ?string $rateLimitDir = null;
private static function sessionLimit(string $type): int
{
$configKey = $type === 'api' ? 'RATE_LIMIT_API' : 'RATE_LIMIT_DEFAULT';
$fallback = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
return (int)($GLOBALS['config'][$configKey] ?? $fallback);
}
/**
* Get the rate limit storage directory
*
@@ -69,24 +82,47 @@ class RateLimitMiddleware
}
/**
* Check IP-based rate limit
* Extract the raw Bearer token from the Authorization header, if present.
* Deliberately independent of ApiKeyAuth: rate limiting must be cheap and
* must not require a DB round-trip to validate the key before counting
* the request, and needs to run whether or not the token turns out to be
* valid. The raw token string (not the validated api_key_id) is hashed as
* the bucket identifier good enough to isolate distinct keys/clients
* from each other without needing to authenticate first.
*
* @param string $type 'default' or 'api'
* @return bool True if request is allowed, false if rate limited
* @return string|null
*/
private static function checkIpRateLimit(string $type = 'default'): bool
private static function getBearerToken(): ?string
{
$ip = self::getClientIp();
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
$now = time();
$header = $_SERVER['HTTP_AUTHORIZATION']
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
?? null;
if ($header === null && function_exists('getallheaders')) {
$headers = getallheaders();
$header = $headers['Authorization'] ?? null;
}
if ($header && preg_match('/^Bearer\s+(.+)$/i', $header, $m)) {
return $m[1];
}
return null;
}
// Create a hash of the IP for the filename (security + filesystem safety)
$ipHash = hash('sha256', $ip . '_' . $type);
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
/**
* Generic file-based sliding-window counter, shared by the IP-based and
* API-key-based buckets below.
*
* @param string $bucketKey Stable identifier for this bucket (already hashed)
* @param int $limit Max requests allowed per window
* @return bool True if this request is within the limit
*/
private static function checkCounter(string $bucketKey, int $limit): bool
{
$now = time();
$filePath = self::getRateLimitDir() . '/' . $bucketKey . '.json';
// 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).
// requests from the same bucket 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).
@@ -122,10 +158,60 @@ class RateLimitMiddleware
flock($fh, LOCK_UN);
fclose($fh);
// Check if over limit
return $rateData['count'] <= $limit;
}
/**
* Read (without incrementing) the current state of a counter bucket, for
* status/header reporting.
*/
private static function peekCounter(string $bucketKey, int $limit): array
{
$now = time();
$filePath = self::getRateLimitDir() . '/' . $bucketKey . '.json';
$rateData = null;
$content = @file_get_contents($filePath);
if ($content !== false && $content !== '') {
$decoded = json_decode($content, true);
if (is_array($decoded)) {
$rateData = $decoded;
}
}
if ($rateData === null || $now - ($rateData['window_start'] ?? $now) >= self::WINDOW_SECONDS) {
return ['limit' => $limit, 'remaining' => $limit, 'reset' => $now + self::WINDOW_SECONDS];
}
return [
'limit' => $limit,
'remaining' => max(0, $limit - $rateData['count']),
'reset' => $rateData['window_start'] + self::WINDOW_SECONDS,
];
}
private static function ipBucketKey(string $type): string
{
return hash('sha256', self::getClientIp() . '_' . $type);
}
private static function apiKeyBucketKey(string $token): string
{
return hash('sha256', 'apikey_' . $token);
}
/**
* Check IP-based rate limit
*
* @param string $type 'default' or 'api'
* @return bool True if request is allowed, false if rate limited
*/
private static function checkIpRateLimit(string $type = 'default'): bool
{
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
return self::checkCounter(self::ipBucketKey($type), $limit);
}
/**
* Clean up old rate limit files (call periodically)
*
@@ -185,7 +271,14 @@ class RateLimitMiddleware
}
/**
* Check rate limit for current request (both session and IP)
* Check rate limit for current request.
*
* Bearer-authenticated requests (Authorization: Bearer ...) are limited
* by a per-token bucket instead of a session a stateless API client
* never sends a session cookie back, so the session-based counter never
* accumulates and starting a session for it is pure overhead. The
* IP-based bucket still applies underneath as defense-in-depth against
* volumetric abuse from one network path.
*
* @param string $type 'default' or 'api'
* @return bool True if request is allowed, false if rate limited
@@ -197,12 +290,17 @@ class RateLimitMiddleware
return false;
}
$token = self::getBearerToken();
if ($token !== null) {
return self::checkCounter(self::apiKeyBucketKey($token), self::API_KEY_LIMIT);
}
// Then check session-based rate limit
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
$limit = self::sessionLimit($type);
$key = 'rate_limit_' . $type;
$now = time();
@@ -270,18 +368,28 @@ class RateLimitMiddleware
}
/**
* Get current rate limit status
* Get current rate limit status.
*
* For a Bearer-authenticated request, reports the per-API-key bucket
* (the one that actually governs it) rather than the session-based
* counter, which is meaningless for a client that never sends a session
* cookie back.
*
* @param string $type 'default' or 'api'
* @return array Rate limit status
*/
public static function getStatus(string $type = 'default'): array
{
$token = self::getBearerToken();
if ($token !== null) {
return self::peekCounter(self::apiKeyBucketKey($token), self::API_KEY_LIMIT);
}
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
$limit = self::sessionLimit($type);
$key = 'rate_limit_' . $type;
$now = time();
+61 -2
View File
@@ -125,13 +125,23 @@ class BulkOperationsModel
$processed = 0;
$failed = 0;
$errors = [];
// Status-change notifications collected during the loop below and
// sent only after a successful commit, matching how the single-ticket
// and Bearer API paths never notify for a change that didn't durably
// land (and how an atomic-mode rollback must not fire any at all).
$notificationQueue = [];
// Load required models
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
$ticketModel = new TicketModel($this->conn);
$auditLogModel = new AuditLogModel($this->conn);
$userModel = new UserModel($this->conn);
$actor = $operation['performed_by'] ? $userModel->getUserById((int)$operation['performed_by']) : null;
$changedByDisplay = $actor['display_name'] ?? $actor['username'] ?? null;
// Batch load all tickets in one query to eliminate N+1 problem
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
@@ -221,8 +231,18 @@ class BulkOperationsModel
'update',
'ticket',
$ticketId,
['status' => 'Closed', 'bulk_operation_id' => $operationId]
[
'status' => ['from' => $currentTicket['status'], 'to' => 'Closed'],
'bulk_operation_id' => $operationId,
]
);
$notificationQueue[] = [
'ticketId' => $ticketId,
'title' => $currentTicket['title'],
'visibility' => $currentTicket['visibility'] ?? 'public',
'oldStatus' => $currentTicket['status'],
'newStatus' => 'Closed',
];
}
}
break;
@@ -291,8 +311,18 @@ class BulkOperationsModel
'update',
'ticket',
$ticketId,
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]
[
'status' => ['from' => $currentTicket['status'], 'to' => $parameters['status']],
'bulk_operation_id' => $operationId,
]
);
$notificationQueue[] = [
'ticketId' => $ticketId,
'title' => $currentTicket['title'],
'visibility' => $currentTicket['visibility'] ?? 'public',
'oldStatus' => $currentTicket['status'],
'newStatus' => $parameters['status'],
];
}
}
}
@@ -364,6 +394,35 @@ class BulkOperationsModel
@unlink($path);
}
}
// Fire the same Matrix/watcher notifications the single-ticket and
// Bearer API status-change paths send, now that every change in
// this batch is durably committed. Best-effort: a notification
// failure must never turn an otherwise-successful bulk operation
// into an error.
foreach ($notificationQueue as $n) {
try {
NotificationHelper::sendStatusChangeNotification(
$n['ticketId'],
$n['oldStatus'],
$n['newStatus'],
$n['title'],
$changedByDisplay,
$n['visibility']
);
NotificationHelper::notifyWatchers(
$this->conn,
$n['ticketId'],
$n['title'],
'status_changed',
['old_status' => $n['oldStatus'], 'new_status' => $n['newStatus'], 'changed_by' => $changedByDisplay],
(int)$operation['performed_by'],
$n['visibility']
);
} catch (Throwable $e) {
error_log("Bulk operation $operationId: notification failed for ticket {$n['ticketId']}: " . $e->getMessage());
}
}
} catch (Exception $e) {
// Rollback on any unexpected error
$this->conn->rollback();
+11
View File
@@ -8,6 +8,9 @@ class CustomFieldModel
{
private $conn;
// Must match custom_field_definitions.field_type's enum() in the schema.
private const ALLOWED_FIELD_TYPES = ['text', 'textarea', 'select', 'checkbox', 'date', 'number'];
public function __construct($conn)
{
$this->conn = $conn;
@@ -87,6 +90,10 @@ class CustomFieldModel
*/
public function createDefinition($data)
{
if (!in_array($data['field_type'] ?? '', self::ALLOWED_FIELD_TYPES, true)) {
return ['success' => false, 'error' => 'Invalid field_type'];
}
$options = null;
if (isset($data['field_options']) && !empty($data['field_options'])) {
$options = json_encode($data['field_options']);
@@ -129,6 +136,10 @@ class CustomFieldModel
*/
public function updateDefinition($fieldId, $data)
{
if (!in_array($data['field_type'] ?? '', self::ALLOWED_FIELD_TYPES, true)) {
return ['success' => false, 'error' => 'Invalid field_type'];
}
$options = null;
if (isset($data['field_options']) && !empty($data['field_options'])) {
$options = json_encode($data['field_options']);
+59
View File
@@ -773,9 +773,68 @@ class TicketModel
$stmt->bind_param("ssis", $visibility, $visibilityGroups, $updatedBy, $ticketId);
$result = $stmt->execute();
$stmt->close();
if ($result) {
$this->pruneWatchersForVisibility($ticketId, $visibility, $visibilityGroups);
}
return $result;
}
/**
* Remove any watchers who no longer qualify for a ticket's access rules
* after its visibility was tightened. Without this, a user watching a
* ticket that's later made confidential/internal (and who isn't
* creator/assignee/admin/in the new visibility_groups) keeps receiving
* Matrix notifications about a ticket canUserAccessTicket() would now
* reject them from opening directly.
*/
private function pruneWatchersForVisibility(string $ticketId, string $visibility, ?string $visibilityGroups): void
{
$ticket = $this->getTicketById($ticketId);
if (!$ticket) {
return;
}
// getTicketById() reflects the just-committed UPDATE, but set these
// explicitly so pruning is correct even if a caller reorders things.
$ticket['visibility'] = $visibility;
$ticket['visibility_groups'] = $visibilityGroups;
$sql = "SELECT tw.user_id, u.is_admin, u.`groups`
FROM ticket_watchers tw
JOIN users u ON tw.user_id = u.user_id
WHERE tw.ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('s', $ticketId);
$stmt->execute();
$watchers = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
$toRemove = [];
foreach ($watchers as $watcher) {
$watcherUser = [
'user_id' => $watcher['user_id'],
'is_admin' => $watcher['is_admin'],
'groups' => $watcher['groups'],
];
if (!$this->canUserAccessTicket($ticket, $watcherUser)) {
$toRemove[] = $watcher['user_id'];
}
}
if (empty($toRemove)) {
return;
}
$placeholders = implode(',', array_fill(0, count($toRemove), '?'));
$delSql = "DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id IN ($placeholders)";
$delStmt = $this->conn->prepare($delSql);
$types = 's' . str_repeat('i', count($toRemove));
$delStmt->bind_param($types, $ticketId, ...$toRemove);
$delStmt->execute();
$delStmt->close();
}
/**
* Delete a ticket and all its associated records.
* Admin-only operation. Removes comments, attachments, watchers, dependencies.
+7 -1
View File
@@ -31,9 +31,15 @@ class WorkflowModel
return $cached;
}
// ORDER BY makes which row wins deterministic (most recently created,
// by transition_id) in the pathological case where two active rows
// exist for the same (from_status, to_status) pair — manage_workflows.php
// now rejects creating that duplicate going forward, but this is a
// defense-in-depth backstop against any duplicate already in the DB.
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
FROM status_transitions
WHERE is_active = TRUE";
WHERE is_active = TRUE
ORDER BY transition_id ASC";
$result = $this->conn->query($sql);
if (!$result) {
+61 -1
View File
@@ -124,7 +124,7 @@ include __DIR__ . '/layout_header.php';
<div class="lt-form-group">
<label class="lt-label" for="category">Category</label>
<select id="category" name="category" class="lt-select">
<select id="category" name="category" class="lt-select" data-action="toggle-custom-fields">
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
@@ -211,6 +211,55 @@ include __DIR__ . '/layout_header.php';
</div>
</div>
<?php if (!empty($allCustomFieldDefs)) : ?>
<!-- ── SECTION 5b: Custom Fields ─────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Additional Fields</div>
<div class="lt-section-body">
<?php foreach ($allCustomFieldDefs as $cfDef) : ?>
<div class="lt-form-group custom-field-group"
data-custom-field-category="<?= htmlspecialchars($cfDef['category'] ?? '', ENT_QUOTES, 'UTF-8') ?>">
<?php
$cfName = 'custom_fields[' . (int)$cfDef['field_id'] . ']';
$cfId = 'custom_field_' . (int)$cfDef['field_id'];
?>
<label class="lt-label" for="<?= $cfId ?>">
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?><?= $cfDef['is_required'] ? ' *' : '' ?>
</label>
<?php if ($cfDef['field_type'] === 'textarea') : ?>
<textarea id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input lt-textarea" rows="3"
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>></textarea>
<?php elseif ($cfDef['field_type'] === 'select') : ?>
<select id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-select"
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
<option value=""> Select </option>
<?php foreach (($cfDef['field_options']['options'] ?? []) as $opt) : ?>
<option value="<?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach ?>
</select>
<?php elseif ($cfDef['field_type'] === 'checkbox') : ?>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="<?= $cfId ?>" name="<?= $cfName ?>" value="1">
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?>
</label>
<?php elseif ($cfDef['field_type'] === 'date') : ?>
<input type="date" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
<?php elseif ($cfDef['field_type'] === 'number') : ?>
<input type="number" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
<?php else : ?>
<input type="text" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
<?php endif ?>
</div>
<?php endforeach ?>
<p class="lt-form-hint">Fields shown depend on the selected Category.</p>
</div>
</div>
<?php endif ?>
<!-- ── SECTION 6: Description ───────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
@@ -316,6 +365,15 @@ include __DIR__ . '/layout_header.php';
.catch(function () { /* silent — duplicate check is non-critical */ });
}
// ── Custom fields: show only the selected category's fields ──
function toggleCustomFields() {
var category = document.getElementById('category').value;
document.querySelectorAll('.custom-field-group').forEach(function (group) {
var fieldCategory = group.getAttribute('data-custom-field-category');
group.classList.toggle('is-hidden', fieldCategory !== '' && fieldCategory !== category);
});
}
// ── Visibility groups toggle ──────────────────────────────
var visibilityHints = {
'public': 'Everyone who is logged in can view this ticket.',
@@ -387,9 +445,11 @@ include __DIR__ . '/layout_header.php';
switch (target.getAttribute('data-action')) {
case 'load-template': loadTemplate(); break;
case 'toggle-visibility-groups': toggleVisibilityGroups(); break;
case 'toggle-custom-fields': toggleCustomFields(); break;
}
});
toggleCustomFields();
if (window.lt) lt.keys.initDefaults();
}());
</script>
+100
View File
@@ -397,6 +397,12 @@ document.addEventListener('DOMContentLoaded', function() {
role="tab" data-tab="dependencies-panel" aria-selected="false" aria-controls="dependencies-panel">
Dependencies
</button>
<?php if (!empty($customFieldDefs)) : ?>
<button type="button" class="lt-tab" id="custom-fields-tab-btn"
role="tab" data-tab="custom-fields-panel" aria-selected="false" aria-controls="custom-fields-panel">
Custom Fields
</button>
<?php endif ?>
<button type="button" class="lt-tab" id="activity-tab-btn"
role="tab" data-tab="activity-panel" aria-selected="false" aria-controls="activity-panel">
Activity
@@ -682,6 +688,60 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
</div>
<?php if (!empty($customFieldDefs)) : ?>
<!-- ═══════════════════════════════════════════════════════════
TAB PANEL: CUSTOM FIELDS
═══════════════════════════════════════════════════════════ -->
<div id="custom-fields-panel" class="lt-tab-panel" role="tabpanel" aria-labelledby="custom-fields-tab-btn">
<div class="lt-frame">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Custom Fields</div>
<div class="lt-section-body">
<div id="customFieldsMsg" class="lt-msg is-hidden lt-mb-md" role="alert" aria-live="polite"></div>
<?php foreach ($customFieldDefs as $cfDef) :
$cfValue = $customFieldValues[$cfDef['field_name']]['field_value'] ?? '';
$cfName = 'custom_fields[' . (int)$cfDef['field_id'] . ']';
$cfId = 'ticket_custom_field_' . (int)$cfDef['field_id'];
?>
<div class="lt-form-group">
<label class="lt-label" for="<?= $cfId ?>">
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?><?= $cfDef['is_required'] ? ' *' : '' ?>
</label>
<?php if ($cfDef['field_type'] === 'textarea') : ?>
<textarea id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input lt-textarea" rows="3"
><?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?></textarea>
<?php elseif ($cfDef['field_type'] === 'select') : ?>
<select id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-select">
<option value=""> Select </option>
<?php foreach (($cfDef['field_options']['options'] ?? []) as $opt) : ?>
<option value="<?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?>"
<?= $opt === $cfValue ? 'selected' : '' ?>><?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach ?>
</select>
<?php elseif ($cfDef['field_type'] === 'checkbox') : ?>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="<?= $cfId ?>" name="<?= $cfName ?>" value="1"
<?= $cfValue === '1' ? 'checked' : '' ?>>
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?>
</label>
<?php elseif ($cfDef['field_type'] === 'date') : ?>
<input type="date" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
value="<?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?>">
<?php elseif ($cfDef['field_type'] === 'number') : ?>
<input type="number" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
value="<?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?>">
<?php else : ?>
<input type="text" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
value="<?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?>">
<?php endif ?>
</div>
<?php endforeach ?>
<button type="button" id="saveCustomFieldsBtn" class="lt-btn lt-btn-primary lt-btn-sm">SAVE CUSTOM FIELDS</button>
</div>
</div>
</div>
<?php endif ?>
<!-- ═══════════════════════════════════════════════════════════
TAB PANEL: ACTIVITY
═══════════════════════════════════════════════════════════ -->
@@ -1013,6 +1073,46 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
// Save custom fields button
var saveCustomFieldsBtn = document.getElementById('saveCustomFieldsBtn');
if (saveCustomFieldsBtn) {
saveCustomFieldsBtn.addEventListener('click', function () {
var panel = document.getElementById('custom-fields-panel');
var msg = document.getElementById('customFieldsMsg');
var values = {};
panel.querySelectorAll('[name^="custom_fields["]').forEach(function (el) {
var m = el.name.match(/custom_fields\[(\d+)\]/);
if (!m) return;
var fieldId = m[1];
if (el.type === 'checkbox') {
values[fieldId] = el.checked ? '1' : '0';
} else {
values[fieldId] = el.value;
}
});
saveCustomFieldsBtn.disabled = true;
msg.classList.add('is-hidden');
lt.api.post('/api/ticket_custom_fields.php', {
ticket_id: window.ticketData.id,
values: values
}).then(function (data) {
saveCustomFieldsBtn.disabled = false;
if (data.success) {
lt.toast.success('Custom fields saved', 3000);
} else {
msg.textContent = data.error || 'Failed to save custom fields';
msg.className = 'lt-msg lt-msg-danger lt-mb-md';
}
}).catch(function (error) {
saveCustomFieldsBtn.disabled = false;
msg.textContent = 'Failed to save custom fields: ' + error.message;
msg.className = 'lt-msg lt-msg-danger lt-mb-md';
});
});
}
// Settings save/cancel
// Load user preference toggles on settings modal open
(function() {
+38
View File
@@ -0,0 +1,38 @@
<?php
/**
* Standalone 500/fatal-error page, rendered by ErrorHandler for page-view
* (non-API) requests.
*
* Deliberately self-contained: a genuine fatal can happen before config.php
* finishes loading, mid-session-start, or mid-DB-query, so this view must
* not depend on $GLOBALS['config'], $GLOBALS['currentUser'], a session, or a
* DB connection being available/working. It links the static base.css
* stylesheet (served directly by the webserver, independent of PHP) to
* match the app's look without going through layout_header.php's app-state
* dependent setup.
*/
?>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>500 &mdash; Something Went Wrong</title>
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="/assets/css/base.css">
</head>
<body>
<div class="lt-frame" style="max-width:32rem;margin:4rem auto">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header lt-text-danger">[ 500 ] SOMETHING WENT WRONG</div>
<div class="lt-section-body lt-text-center">
<p class="lt-text-muted lt-mb-md">
An unexpected error occurred. It's been logged; please try again shortly.
</p>
<a href="/" class="lt-btn lt-btn-primary">&larr; Dashboard</a>
</div>
</div>
</body>
</html>
+2 -7
View File
@@ -235,8 +235,7 @@
}
function loadNotifications() {
return fetch('/api/notifications.php', { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
return lt.api.get('/api/notifications.php')
.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>';
@@ -251,11 +250,7 @@
if (clearBtn) {
clearBtn.addEventListener('click', function() {
fetch('/api/notifications.php', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN || '' },
body: JSON.stringify({ action: 'mark_read' })
}).then(loadNotifications);
lt.api.post('/api/notifications.php', { action: 'mark_read' }).then(loadNotifications);
});
}