Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:55:50 -04:00
jaredandClaude Opus 4.8 e0e92e326a Quick-win fixes from second review
Security / PHP Security (semgrep) (push) Successful in 1m27s
Lint / Deploy (push) Successful in 4s
Lint / Notify on failure (push) Has been skipped
Lint / PHP (phpcs PSR-12) (push) Successful in 20s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 38s
- create_ticket_api.php: validate status (against TICKET_STATUSES) and
  priority (numeric 1-5). A non-numeric priority previously cast to 0 and
  escalated the ticket below P1 on the dedup/update path.
- manage_workflows.php: reject empty/invalid from_status/to_status on POST
  and PUT (must be valid ticket statuses) so the workflow table can't be
  populated with bogus transitions.
- TicketModel::getAllTickets: COUNT(*) OVER() rides on returned rows, so a
  page past the last row returned total/pages = 0. Fall back to a direct
  COUNT when an over-range page yields no rows, keeping pager math correct.
- DashboardView: stop double-escaping category/type/assigned active-filter
  labels (they were htmlspecialchars'd into the label and again at output,
  rendering R&D as R&amp;D); output escaping is retained.
- check_duplicates.php / NotificationHelper::notifyWatchers: wrap the DB
  lookups in try/catch so a failed prepare/query degrades gracefully
  (advisory dup-check returns none; best-effort watcher notify is skipped)
  instead of fataling the request. Works whether mysqli throws or returns
  false. (manage_* endpoints already have a top-level try/catch.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:26:28 -04:00
jaredandClaude Opus 4.8 4164f85051 Fix bugs found in second multi-agent review
Security / PHP Security (semgrep) (push) Successful in 1m14s
Lint / Deploy (push) Successful in 3s
Lint / PHP (phpcs PSR-12) (push) Successful in 19s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 24s
Lint / Notify on failure (push) Has been skipped
XSS / security:
- markdown.js: sanitize footnote labels to a safe slug before using them in
  id/href attributes. Labels are captured before the HTML-escape pass, so a
  label like x"><img onerror=...> broke out → stored XSS (the earlier quote-
  escape fix didn't cover this path). Verified neutralized.
- RateLimitMiddleware: only trust X-Forwarded-For / X-Real-IP when REMOTE_ADDR
  is a configured trusted proxy, and use the rightmost (proxy-appended) entry.
  Previously any client could rotate XFF to escape the per-IP rate limit.
- .env.example: document TRUSTED_PROXIES so fresh deploys aren't fail-open on
  the Authelia forward-auth spoofing protection.

Correctness:
- notifications.php: my previous assigned-to LIKE fix anchored only on '}', so
  BULK assignments (logged {"assigned_to":N,"bulk_operation_id":..}) produced
  no "assigned to you" notification — now matches both '}' and ',' delimiters.
- notifications.php: implement the documented @mention notifications (query
  action_type='mention' rows for the current user); they were never delivered.
- NotificationHelper::notifyWatchers: guard unchecked prepare() so a missing
  ticket_watchers table can't fatal the request after its DB write committed.
- AuditLogModel::getTicketTimeline: JSON_UNQUOTE the extracted ticket_id so
  comment events actually match (string vs JSON-number comparison never did).
- AuditLogModel/audit_log.php: CSV export no longer silently truncates to the
  1000-row UI cap; uses a dedicated higher export limit.
- DashboardView: quick-preview drawer read .ticket-link from the title cell
  (which has none), so the title was always blank — use the cell text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:00:36 -04:00
jaredandClaude Opus 4.8 b2c19745eb Add trusted-proxy auth hardening + PHP requirements checks
Lint / PHP (phpcs PSR-12) (push) Successful in 20s
Lint / JS (eslint) (push) Successful in 11s
Lint / PHP requirements (version + extensions) (push) Successful in 52s
Security / PHP Security (semgrep) (push) Successful in 2m6s
Lint / Deploy (push) Successful in 13s
Lint / Notify on failure (push) Has been skipped
Trusted-proxy hardening (defense-in-depth for Authelia forward-auth):
- AuthMiddleware now only honors Remote-* identity headers when REMOTE_ADDR
  is in a configured TRUSTED_PROXIES allowlist; otherwise it refuses with 403
  and logs an 'untrusted_proxy' security event. Previously anything that could
  reach PHP directly could spoof Remote-User/Remote-Groups and log in as admin.
- New config TRUSTED_PROXIES (comma-separated, from .env). Empty = enforcement
  off, so this is backward compatible until the allowlist is set on a host.

Requirements checks (so a PHP upgrade dropping an extension can't silently
break features like avatars again):
- config/requirements.php: single source of truth for min PHP version and
  required extensions (ldap, mysqli, curl, mbstring, fileinfo, json).
- scripts/check_requirements.php: CI script that fails the build if the
  environment doesn't satisfy them.
- New 'requirements' CI job installs those extensions and runs the check;
  deploy now depends on it.
- api/health.php: adds php_extensions + php_version checks so production
  monitoring surfaces the drift (returns 503 if a required extension is gone).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:22:53 -04:00
jaredandClaude Opus 4.8 b3bc3ab159 Harden recurring cron, bulk delete, error handling; fix semgrep CI
Lint / PHP (phpcs PSR-12) (push) Successful in 41s
Lint / JS (eslint) (push) Successful in 8s
Security / PHP Security (semgrep) (push) Successful in 1m17s
Lint / Deploy (push) Successful in 13s
Lint / Notify on failure (push) Has been skipped
Continued fixes from the multi-agent review:

- recurring tickets cron: now that the parse error is fixed the job runs,
  exposing two latent bugs. (1) next_run_at was only advanced after the
  full success path, so any failure (e.g. a NULL created_by passed to the
  non-nullable assignTicket() $assignedBy -> TypeError) left it in the past
  and re-created a duplicate ticket every cron cycle. Added an atomic
  claimForRun() (conditional UPDATE gated on still-due) called BEFORE
  creation, which also prevents overlapping runs from double-creating.
  (2) The cron used a raw mysqli with no utf8mb4, corrupting non-ASCII
  content; it now uses Database::getConnection(). Also guard the assignment
  so created_by NULL falls back to the assignee.
- bulk delete: attachment files were unlinked inside the DB transaction, so
  an atomic-mode rollback restored rows but the files were already gone.
  deleteTicket() can now defer file removal to the caller, and
  BulkOperationsModel deletes files only after a successful commit.
- UserModel: back-tick the `groups` column (reserved word on MySQL 8.0.2+).
- create_ticket_api.php: stop leaking raw DB/exception messages to callers;
  log server-side and return a generic error. (Also includes a pre-existing
  working-tree tweak that adds title to the manual-ticket dedupe hash.)
- CI: semgrep install failed under PEP 668; add --break-system-packages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:34:57 -04:00
jaredandClaude Opus 4.8 2b8d593ab0 Fix issues found in multi-agent code review
Lint / PHP (phpcs PSR-12) (push) Successful in 28s
Lint / JS (eslint) (push) Successful in 19s
Security / PHP Security (semgrep) (push) Failing after 42s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
Verified, high-confidence fixes from a project-wide review:

- markdown.js: escape " and ' in the HTML-escape step. User-controlled
  image/link URLs and alt text were interpolated into "..." attributes
  without quote escaping, allowing attribute breakout and injected event
  handlers (stored XSS, only mitigated by CSP). Flagged independently by
  two reviewers.
- cron/create_recurring_tickets.php & cron/cleanup_ratelimit.php: a
  mangled crontab example inside the docblock contained */ which closed
  the comment early, causing a fatal parse error — both cron jobs never
  ran. Rewrote the docblocks without a literal */.
- update_ticket.php: validate visibility BEFORE the core DB write so an
  invalid payload can't leave the ticket updated while the request reports
  failure (which also skipped the audit delta and stats cache invalidation).
- watch_ticket.php: GET watcher_count was capped at 6 (count of a LIMIT 6
  list); use an unbounded COUNT(*) so it matches the POST path.
- notifications.php: "assigned to me" LIKE pattern lacked a trailing
  delimiter, so user 12 also matched 120/123/etc.; anchor with }.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:14:38 -04:00
jaredandClaude Opus 4.8 600c46f673 Fix CSP-blocked command palette trigger (inline onclick -> addEventListener)
Lint / PHP (phpcs PSR-12) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 10s
Security / PHP Security (semgrep) (push) Failing after 2m57s
Lint / Deploy (push) Successful in 8s
Lint / Notify on failure (push) Has been skipped
The ⌘K header button used an inline onclick handler, which the CSP
(script-src-attr, nonce-based, no unsafe-inline) blocks, so clicking the
button did nothing. Move the handler into the existing nonce'd script
block and bind it via addEventListener on #lt-cmd-trigger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:04:57 -04:00
jaredandClaude Opus 4.8 5808b93cdb Fix avatar negative-cache poisoning on transient LDAP errors
Lint / PHP (phpcs PSR-12) (push) Successful in 57s
Lint / JS (eslint) (push) Successful in 9s
Security / PHP Security (semgrep) (push) Failing after 41s
Lint / Deploy (push) Successful in 5s
Lint / Notify on failure (push) Has been skipped
user_avatar.php wrote a ".none" sentinel whenever it failed to obtain
avatar bytes, conflating "LDAP errored/timed out" with "user has no
avatar". A brief lldap blip (restart, slow response past the 3s timeout,
network hiccup) therefore cached a 404 for the full AVATAR_CACHE_TTL
(1h default), leaving avatars broken long after lldap recovered.

Track whether the LDAP query actually completed (`$ldapQueryOk`) and only
write the negative-cache sentinel when lldap genuinely answered with no/
invalid avatar. On error/timeout, leave no sentinel so the lookup retries
once lldap is healthy again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:01:17 -04:00
jaredandClaude Sonnet 4.6 597e1b1eea fix: correct phpcs indentation on SLA banner conditional block
Lint / PHP (phpcs PSR-12) (push) Successful in 24s
Lint / JS (eslint) (push) Successful in 11s
Security / PHP Security (semgrep) (push) Successful in 1m13s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
PHP inline conditionals inside HTML context must use 4-space indentation
to satisfy PSR-12 Generic.WhiteSpace.ScopeIndent rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:43:49 -04:00
jaredandClaude Sonnet 4.6 35a2b66038 refactor: migrate P1/P2 SLA banner to lt-sla-p1/lt-sla-p2 component
Lint / PHP (phpcs PSR-12) (push) Failing after 24s
Lint / JS (eslint) (push) Successful in 11s
Lint / Deploy (push) Has been cancelled
Lint / Notify on failure (push) Has been cancelled
Security / PHP Security (semgrep) (push) Has been cancelled
Replaces the lt-alert workaround with the new purpose-built SLA banner
component now in base.css:
- lt-sla-p1 (pulsing red) / lt-sla-p2 (static amber) wrapper classes
- Structured subcomponents: lt-sla-icon, lt-sla-info, lt-sla-title,
  lt-sla-bar + lt-sla-fill (gradient fill), lt-sla-meta, lt-sla-dismiss
- Dismiss now uses banner.hidden + sessionStorage key lt_sla_dismissed_<id>
  (aligns with web_template pattern; previous code used classList 'dismissed')
- Elapsed/remaining/breach state driven by same tick() interval, now updating
  lt-sla-fill width instead of a separate lt-progress bar inside lt-alert-msg

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:40:15 -04:00
jaredandClaude Sonnet 4.6 b7aea8c683 sync: pull progress gradient fills and SLA banner from web_template v1.2
Lint / PHP (phpcs PSR-12) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 12s
Security / PHP Security (semgrep) (push) Successful in 1m12s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
Progress bars now use linear-gradient fills for a more dramatic terminal
readout appearance (matches web_template 39862fa):
- Default (orange), --cyan, --green, --red variants all upgraded from flat
  accent colors to directional gradients with highlight endpoints

SLA banner component (lt-sla-p1 / lt-sla-p2) added to base.css, replacing
the lt-alert workaround previously used for P1/P2 SLA display:
- lt-sla-p1: pulsing red banner (animation: lt-sla-pulse 2s)
- lt-sla-p2: static amber banner
- Subcomponents: icon, info, title, bar, fill, meta, dismiss
- Both fills use gradients for visual consistency (P2 amber→#ffd740)
- lt-sla-dismiss includes transition + :focus-visible ring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:29:57 -04:00
jaredandClaude Sonnet 4.6 d23bbc4b26 docs: fix CI/CD section and add security badge
Lint / PHP (phpcs PSR-12) (push) Successful in 50s
Lint / JS (eslint) (push) Successful in 14s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
Security / PHP Security (semgrep) (push) Successful in 2m7s
- Add security.yml badge to header
- Replace stale 'npm audit' description with actual semgrep config
- Add deploy tagging and notify-failure rows that were missing
- Fix ESLint config location note

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 14:04:28 -04:00
jaredandClaude Sonnet 4.6 132098bee3 Exclude two more semgrep false-positive rules from security scan
Lint / PHP (phpcs PSR-12) (push) Successful in 30s
Lint / JS (eslint) (push) Successful in 13s
Security / PHP Security (semgrep) (push) Successful in 1m18s
Lint / Deploy (push) Successful in 5s
Lint / Notify on failure (push) Has been skipped
- tainted-filename: filenames in upload_attachment.php and user_avatar.php
  are derived exclusively from (int)-cast integers; no user string reaches
  the filesystem path. Semgrep's taint engine tracks all use-sites of the
  variable, producing findings on every file_exists/readfile/unlink call.
- tainted-callable: index.php audit-log query passes \$sql to prepare();
  \$sql is assembled from hardcoded SQL fragments with ? placeholders and
  explicit (int) LIMIT/OFFSET casts. User values are bound via bind_param,
  never interpolated. Semgrep cannot see through the WHERE-builder logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:51:02 -04:00
65 changed files with 2681 additions and 760 deletions
+21
View File
@@ -1,5 +1,11 @@
# Tinker Tickets Environment Configuration
# Copy this file to .env and fill in your values
#
# NOTE: This file is parsed with parse_ini_file(). Any value containing special
# characters (#, ;, =, quotes, spaces, etc.) MUST be wrapped in double quotes,
# e.g. DB_PASS="p@ss;word#1". The application now fails loudly (dies with a clear
# error) if the .env file cannot be parsed, so an unquoted special character will
# take the whole app down rather than silently using a wrong value.
# Database Configuration
DB_HOST=10.10.10.50
@@ -24,6 +30,21 @@ APP_DOMAIN=
# Include all domains that can access this application
ALLOWED_HOSTS=localhost,127.0.0.1
# Trusted reverse proxy IP(s), comma-separated (e.g. the Authelia/nginx proxy).
# Set this to the IP address(es) of your reverse proxy. Authelia forward-auth
# headers (Remote-User / Remote-Groups) and forwarded client IPs are only
# trusted when REMOTE_ADDR is in this list.
#
# Leaving this EMPTY disables reverse-proxy verification entirely: the app then
# trusts Remote-User / Remote-Groups headers from ANY source. That is unsafe if
# the PHP backend is reachable directly (bypassing the proxy), because a client
# can then spoof those headers and log in as an admin. Only leave it empty when
# network topology guarantees PHP is reachable solely via the trusted proxy.
#
# Exact IP match only (no CIDR). Example (single proxy): TRUSTED_PROXIES=10.10.10.27
# Example (multiple): TRUSTED_PROXIES=10.10.10.27,10.10.10.28
TRUSTED_PROXIES=
# Timezone (default: America/New_York)
TIMEZONE=America/New_York
+21 -4
View File
@@ -11,7 +11,7 @@ jobs:
name: PHP (phpcs PSR-12)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- name: Install PHP and phpcs
run: |
@@ -27,7 +27,7 @@ jobs:
name: JS (eslint)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- name: Install ESLint
run: npm install --save-dev eslint@8
@@ -35,10 +35,27 @@ jobs:
- name: Run ESLint
run: npx eslint assets/js/
requirements:
name: PHP requirements (version + extensions)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- name: Install PHP with required extensions
run: |
apt-get update -qq
# Install the extensions declared in config/requirements.php so the
# check verifies they are actually installable + loadable, and so this
# build fails if a required extension can't be provided.
apt-get install -y -qq php-cli php-ldap php-mysql php-curl php-mbstring
- name: Verify runtime requirements
run: php scripts/check_requirements.php
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: [php-lint, js-lint]
needs: [php-lint, js-lint, requirements]
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/development')
permissions:
contents: write
@@ -77,7 +94,7 @@ jobs:
notify-failure:
name: Notify on failure
runs-on: ubuntu-latest
needs: [php-lint, js-lint]
needs: [php-lint, js-lint, requirements]
if: failure() && github.event_name == 'push'
steps:
- name: Send Matrix alert
+6 -2
View File
@@ -13,16 +13,20 @@ jobs:
name: PHP Security (semgrep)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- name: Install semgrep
run: |
apt-get update -qq
apt-get install -y -qq python3 python3-pip
pip3 install semgrep
# Debian's Python is externally managed (PEP 668); the runner is
# ephemeral so installing system-wide is fine here.
pip3 install --break-system-packages semgrep
- name: Run semgrep
run: |
semgrep --config=p/php --config=p/owasp-top-ten --error \
--exclude-rule=php.lang.security.injection.echoed-request.echoed-request \
--exclude-rule=php.lang.security.injection.tainted-filename.tainted-filename \
--exclude-rule=php.lang.security.injection.tainted-callable.tainted-callable \
.
+43 -19
View File
@@ -1,6 +1,7 @@
# Tinker Tickets
[![Lint](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions/workflows/lint.yml/badge.svg)](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=lint.yml)
[![Security](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions/workflows/security.yml/badge.svg)](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=security.yml)
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic.
@@ -72,7 +73,7 @@ The following features are intentionally **not planned** for this system:
- **Duplicate Detection**: Similarity check on ticket title surfaces potential duplicates with one-click linking
- **Activity Timeline**: Full `lt-timeline` audit trail — color-coded by event type (status, comment, assign, attach)
- **Watcher Avatars**: Avatar group shows who is watching a ticket; tooltip lists all names
- **SLA Timer**: P1/P2 tickets display a live elapsed-time banner with progress bar (P1 = 8 h, P2 = 24 h, P3 = 72 h)
- **SLA Timer**: P1/P2 tickets display a live elapsed-time banner with progress bar (P1 = 8 h, P2 = 24 h). Lower priorities (P3P5) have no SLA banner.
- **Priority Alert Banner**: P1 shows a sticky error banner; P2 shows a warning banner — dismissible per session
### Ticket Templates
@@ -120,7 +121,7 @@ The following features are intentionally **not planned** for this system:
- **Powered by audit_log**: No extra table — notifications are derived from existing audit trail
### Matrix Notifications (hookshot)
- **Ticket Created**: Fires when any ticket is created (manual or via API)
- **Ticket Created**: Fires when a ticket is created via the manual form, the external API (hwmonDaemon), or the recurring-ticket cron. (Cloned tickets do not fire this event.)
- **Status Changed**: Fires on every status transition
- **@Mentions**: Mentioned users receive a direct Matrix notification
- **Assignment**: Optional — set `MATRIX_NOTIFY_ASSIGNMENTS=1` to enable
@@ -149,7 +150,7 @@ The following features are intentionally **not planned** for this system:
| `?` | Show keyboard shortcuts help |
### Security Features
- **CSRF Protection**: Token-based protection with constant-time comparison; token rotated after each write
- **CSRF Protection**: Token-based protection with constant-time comparison. `bootstrap.php` rotates the token on a successful write and returns the current token in every response (including on rejection); the client (`lt.api`) resyncs from that value. Rejected requests do not rotate the token.
- **Rate Limiting**: Session-based AND IP-based rate limiting to prevent abuse
- **Security Headers**: CSP with nonces (no unsafe-inline), X-Frame-Options, X-Content-Type-Options
- **SQL Injection Prevention**: All queries use prepared statements with parameter binding
@@ -178,9 +179,9 @@ Content-Type: application/json
**Key behaviours:**
- Authenticated via `Authorization: Bearer` header — API key stored in `/etc/hwmonDaemon/.env`
- **Deduplication**: Generates a SHA-256 hash from the issue category, hostname, and device; rejects duplicate tickets within 24 hours
- **Deduplication**: Generates a SHA-256 hash from the issue category, hostname, and device (no time window). A repeat alert matching an existing **open** ticket updates its title/description and escalates the priority if the condition worsened; if the matching ticket was already **closed**, it is reopened instead of creating a new one
- Cluster-wide issues (Ceph health, etc.) deduplicate across all nodes (hostname excluded from hash)
- Matrix notification sent automatically after ticket creation
- Matrix notification sent automatically on ticket creation, priority escalation, and reopen
- API key must be generated at `/admin/api-keys`; the key goes in hwmonDaemon's `/etc/hwmonDaemon/.env` as `TICKET_API_KEY`
## Technical Architecture
@@ -239,6 +240,11 @@ Content-Type: application/json
- `tickets`: `ticket_id` (unique), `status`, `priority`, `created_at`, `created_by`, `assigned_to`, `visibility`
- `audit_log`: `user_id`, `action_type`, `entity_type`, `created_at`
### Database Schema / Migrations
- `migrations/000_baseline.sql` is the full schema baseline for the whole database. It is written to be safe to re-run (idempotent) and is the source of truth for a fresh install.
- `php migrations/migrate.php` applies any pending migration files in `migrations/` in order, tracking applied files in the `migrations` table. Use `--status` to list state and `--dry-run` to preview without executing.
### API Endpoints
| Endpoint | Method | Description |
@@ -247,6 +253,7 @@ Content-Type: application/json
| `/api/update_ticket.php` | POST | Update ticket with workflow validation |
| `/api/assign_ticket.php` | POST | Assign ticket to user |
| `/api/add_comment.php` | POST | Add comment to ticket |
| `/api/get_comments.php` | GET | Fetch paginated comments for a ticket |
| `/api/clone_ticket.php` | POST | Clone an existing ticket |
| `/api/get_template.php` | GET | Fetch ticket template |
| `/api/get_users.php` | GET | Get user list for assignments |
@@ -291,6 +298,7 @@ tinker_tickets/
│ ├── download_attachment.php # GET: Download with visibility check
│ ├── export_tickets.php # GET: Export tickets to CSV/JSON
│ ├── generate_api_key.php # POST: Generate API key (admin)
│ ├── get_comments.php # GET: Fetch paginated ticket comments
│ ├── get_template.php # GET: Fetch ticket template
│ ├── get_users.php # GET: Get user list
│ ├── health.php # GET: Health check endpoint
@@ -328,14 +336,20 @@ tinker_tickets/
├── config/
│ └── config.php # Config + .env loading
├── controllers/
│ ├── CommentController.php # Comment create/edit/delete + notifications
│ ├── DashboardController.php # Dashboard with stats + filters
│ └── TicketController.php # Ticket CRUD + timeline + visibility
├── cron/
│ ├── cleanup_audit_log.php # Delete audit_log rows past retention (daily)
│ ├── cleanup_ratelimit.php # Purge expired rate-limit files (every few min)
│ └── create_recurring_tickets.php # Process recurring ticket schedules
├── helpers/
│ ├── CacheHelper.php # File-based cache (stats, avatars)
│ ├── Database.php # Centralized mysqli connection
│ ├── ErrorHandler.php # Global error/exception handler
│ ├── NotificationHelper.php # Matrix hookshot webhook events
│ ├── OutputHelper.php # Safe HTML output helpers
│ ├── ResponseHelper.php # JSON API response helpers
│ ├── SynapseHelper.php # Resolves usernames → Matrix IDs via Synapse admin API
│ └── UrlHelper.php # Canonical ticket URLs using APP_DOMAIN
├── middleware/
@@ -346,6 +360,7 @@ tinker_tickets/
│ └── SecurityHeadersMiddleware.php # CSP headers with per-request nonce generation
├── models/
│ ├── ApiKeyModel.php # API key generation/validation
│ ├── AttachmentModel.php # Ticket file attachment metadata
│ ├── AuditLogModel.php # Audit logging + timeline
│ ├── BulkOperationsModel.php # Bulk operations tracking
│ ├── CommentModel.php # Comment data access
@@ -359,11 +374,12 @@ tinker_tickets/
│ ├── UserModel.php # User management + groups
│ ├── UserPreferencesModel.php # User preferences
│ └── WorkflowModel.php # Status transition workflows
├── migrations/
│ ├── 000_baseline.sql # Full schema baseline (safe to re-run)
│ └── migrate.php # CLI migration runner (tracks applied migrations)
├── scripts/
│ ├── add_closed_at_column.php # Migration: add closed_at column to tickets
── add_comment_updated_at.php # Migration: add updated_at column to ticket_comments
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads (run manually or via cron)
│ └── create_dependencies_table.php # Create ticket_dependencies table
│ ├── check_requirements.php # Verify PHP extensions/config prerequisites
── cleanup_orphan_uploads.php # Delete orphaned upload files past grace period (cron)
├── uploads/ # File attachment storage
│ └── avatars/ # lldap avatar disk cache
├── views/
@@ -454,13 +470,20 @@ AVATAR_CACHE_TTL=3600
### 2. Cron Jobs
Add to crontab for recurring tickets and optional cleanup:
Add to crontab for recurring tickets and maintenance cleanup:
```bash
# Run every hour to create scheduled recurring tickets
0 * * * * php /path/to/tinkertickets/cron/create_recurring_tickets.php
# Optional: clean up orphaned uploads weekly
0 3 * * 0 php /path/to/tinkertickets/scripts/cleanup_orphan_uploads.php
# Purge expired rate-limit files (every 5 minutes)
*/5 * * * * php /path/to/tinkertickets/cron/cleanup_ratelimit.php
# Delete audit_log rows older than AUDIT_LOG_RETENTION_DAYS (daily)
30 3 * * * php /path/to/tinkertickets/cron/cleanup_audit_log.php
# Delete orphaned upload files with no attachment row, past a 24h grace period (daily).
# Add --dry-run to preview without deleting.
0 4 * * * php /path/to/tinkertickets/scripts/cleanup_orphan_uploads.php
```
### 3. File Uploads
@@ -501,7 +524,7 @@ Key conventions and gotchas for working with this codebase:
3. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
4. **Config path**: `config/config.php` (not `config/db.php`)
5. **Comments table**: `ticket_comments` (not `comments`)
6. **CSRF**: Required for all POST/DELETE requests via `X-CSRF-Token` header; bootstrap.php rotates token and returns it in `csrf_token` field of all `apiRespond()` responses
6. **CSRF**: Required for all POST/DELETE requests via `X-CSRF-Token` header. `bootstrap.php` rotates the token only on a successful write and returns the current token in the `csrf_token` field of every `apiRespond()` response (including rejections), so the client can resync. A rejected request keeps the existing token.
7. **Cache busting**: `ASSET_VERSION` is auto-computed from asset file mtimes; override with `ASSET_VERSION=` in `.env`
8. **Ticket linking**: Use `#123456789` in markdown-enabled comments
9. **User groups**: Stored in `users.groups` as comma-separated values
@@ -519,8 +542,8 @@ Key conventions and gotchas for working with this codebase:
21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`.
22. **`utils.js` on all pages**: `utils.js` is loaded by all views (including admin). It provides `escapeHtml()`, `getTicketIdFromUrl()`, and `showConfirmModal()`.
23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`.
24. **Stats cache**: `StatsModel` caches stats for 60 s. Any API that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after changes (bulk_operation, assign_ticket, update_ticket, clone_ticket all do this).
25. **External API (`create_ticket_api.php`)**: Uses `ApiKeyAuth` (Bearer token), not session auth. Served directly by the web server from the document root — not through the index.php router. Includes deduplication logic to prevent duplicate hw-alert tickets within 24 h.
24. **Stats cache**: `StatsModel` caches stats for 60 s. Any path that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after the change. Callers: `TicketController::create` (manual create), `create_ticket_api.php` (external API create/escalate/reopen), `cron/create_recurring_tickets.php`, `bulk_operation`, `assign_ticket`, `update_ticket`, and `clone_ticket`.
25. **External API (`create_ticket_api.php`)**: Uses `ApiKeyAuth` (Bearer token), not session auth. Served directly by the web server from the document root — not through the index.php router. Includes deduplication logic (SHA-256 hash, no time window) that updates/escalates an existing open duplicate or reopens a closed one rather than creating a new ticket.
## File Reference
@@ -556,7 +579,7 @@ Key conventions and gotchas for working with this codebase:
|---------|---------------|
| SQL Injection | All queries use prepared statements with parameter binding |
| XSS Prevention | HTML escaped in markdown parser; CSP with per-request nonces |
| CSRF Protection | Token-based with constant-time comparison (`hash_equals`); rotated on each write |
| CSRF Protection | Token-based with constant-time comparison (`hash_equals`); rotated on successful writes, current token returned in every response (including rejections) for the client to resync — rejected requests do not rotate |
| Session Security | Fixation prevention, secure cookies, session timeout |
| Rate Limiting | Session-based + IP-based (file storage) |
| File Security | Path traversal prevention, MIME type validation, uploads `.htaccess` blocks execution |
@@ -569,12 +592,13 @@ Key conventions and gotchas for working with this codebase:
|---|---|---|
| `lint.yml` (php-lint) | phpcs PSR-12 standard | Every push and PR |
| `lint.yml` (js-lint) | ESLint on `assets/js/` | Every push and PR |
| `security.yml` | `npm audit --audit-level=high` (not applicable — no runtime npm deps) | — |
| `deploy` job in `lint.yml` | Calls deploy webhooks on CT132 (10.10.10.45): `tinker-deploy` (main) or `tinker-beta-deploy` (development) | Push to `main` or `development`, after both lint jobs pass |
| `security.yml` | semgrep with `p/php` + `p/owasp-top-ten` configs | Every push, PR, and weekly (Monday 6am) |
| `deploy` job in `lint.yml` | Calls deploy webhooks on CT132 (10.10.10.45): `tinker-deploy` (main) or `tinker-beta-deploy` (development); tags deployed commit `deploy-YYYY.MM.DD-N` | Push to `main` or `development`, after both lint jobs pass |
| `notify-failure` job in `lint.yml` | Posts CI failure alert to Matrix via webhook | Push to any branch when lint fails |
Branch protection is enabled on `main` — both lint jobs must pass before any PR can merge.
Lint config: `.phpcs.xml` (PSR-12 with project-specific tweaks), `.eslintrc.json` per directory.
Lint config: `.phpcs.xml` (PSR-12 with project-specific tweaks), `.eslintrc.json` (root, browser env).
## License
+59 -7
View File
@@ -38,19 +38,29 @@ try {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
ob_end_clean();
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// CSRF Protection
// CSRF Protection for all state-changing methods (any non-GET/HEAD request)
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode([
'success' => false,
'error' => 'Invalid CSRF token',
'csrf_token' => CsrfMiddleware::getToken()
]);
exit;
}
// Rotate token after successful validation
$newCsrfToken = CsrfMiddleware::rotateToken();
}
$currentUser = $_SESSION['user'];
@@ -63,7 +73,11 @@ try {
$data = json_decode(file_get_contents('php://input'), true);
if (!$data) {
throw new Exception("Invalid JSON data received");
http_response_code(400);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid JSON data received']);
exit;
}
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
@@ -75,6 +89,20 @@ try {
exit;
}
// Reject empty/whitespace-only comments
$commentTextRaw = isset($data['comment_text']) ? trim((string)$data['comment_text']) : '';
if ($commentTextRaw === '') {
http_response_code(400);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Comment text cannot be empty']);
exit;
}
// Never trust a client-supplied display name — always attribute the comment to
// the authenticated session user.
$data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
// Verify user can access the ticket before allowing a comment
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
@@ -97,6 +125,18 @@ try {
$commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn);
// If replying, the parent comment must belong to this same (accessible) ticket.
if (isset($data['parent_comment_id']) && $data['parent_comment_id'] !== null && $data['parent_comment_id'] !== '') {
$parentComment = $commentModel->getCommentById((int)$data['parent_comment_id']);
if (!$parentComment || (string)$parentComment['ticket_id'] !== (string)$ticketId) {
http_response_code(400);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid parent comment']);
exit;
}
}
// Extract @mentions from comment text
$mentions = $commentModel->extractMentions($data['comment_text'] ?? '');
$mentionedUsers = [];
@@ -130,6 +170,7 @@ try {
$authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$commentText = $data['comment_text'] ?? '';
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
$ticketVisibility = $ticket['visibility'] ?? 'public';
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API
if (!empty($mentionedUsers)) {
@@ -142,7 +183,14 @@ try {
// General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
NotificationHelper::sendCommentNotification($ticketId, $ticketTitle, $commentText, $authorDisplay);
NotificationHelper::sendCommentNotification(
$ticketId,
$ticketTitle,
$commentText,
$authorDisplay,
$ticketVisibility !== 'public',
$ticketVisibility
);
}
// Notify watchers of the new comment
@@ -152,7 +200,8 @@ try {
$ticketTitle,
'comment_added',
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
(int)$userId
(int)$userId,
$ticketVisibility
);
// Add mentioned users to result for frontend
@@ -165,6 +214,9 @@ try {
if ($result['success']) {
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
$result['user_id'] = $userId;
if (isset($newCsrfToken)) {
$result['csrf_token'] = $newCsrfToken;
}
}
// Discard any unexpected output
+23 -5
View File
@@ -9,6 +9,22 @@
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
/**
* Neutralize CSV/formula injection: prefix a leading apostrophe to any cell that
* a spreadsheet (Excel/Sheets) would otherwise evaluate as a formula.
*
* @param mixed $value
* @return string
*/
function auditCsvSafeCell($value): string
{
$value = (string)$value;
if ($value !== '' && in_array($value[0], ['=', '+', '-', '@', "\t", "\r"], true)) {
return "'" . $value;
}
return $value;
}
// Check admin status - audit log viewing is admin-only
if (!$isAdmin) {
http_response_code(403);
@@ -46,8 +62,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$filters['ip_address'] = $_GET['ip_address'];
}
// Get all matching logs (no limit for CSV export)
$result = $auditLogModel->getFilteredLogs($filters, 10000, 0);
// Get all matching logs for export. The forExport flag raises the cap
// (model clamps to its export limit) so the CSV isn't silently truncated
// to the 1000-row UI page limit.
$result = $auditLogModel->getFilteredLogs($filters, PHP_INT_MAX, 0, true);
$logs = $result['logs'];
// Set CSV headers
@@ -67,8 +85,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$details = json_encode($log['details']);
}
fputcsv($output, [
$log['log_id'],
fputcsv($output, array_map('auditCsvSafeCell', [
$log['audit_id'] ?? ($log['log_id'] ?? ''),
$log['created_at'],
$log['display_name'] ?? $log['username'] ?? 'N/A',
$log['action_type'],
@@ -76,7 +94,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$log['entity_id'] ?? 'N/A',
$log['ip_address'] ?? 'N/A',
$details
]);
]));
}
fclose($output);
+8 -1
View File
@@ -34,9 +34,16 @@ if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'DELETE'])) {
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
// Do NOT rotate on a rejected request. Return the current valid token so a
// client whose token drifted out of sync can recover on its next request
// (the response body is same-origin only, so this can't aid a CSRF attacker).
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode([
'success' => false,
'error' => 'Invalid CSRF token',
'csrf_token' => CsrfMiddleware::getToken()
]);
exit;
}
// Rotate token after successful validation; endpoints include it in their JSON response
+4 -2
View File
@@ -19,9 +19,9 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
exit;
}
// CSRF Protection
// CSRF Protection for all state-changing methods (any non-GET/HEAD request)
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
@@ -47,6 +47,7 @@ $parameters = $data['parameters'] ?? null;
// Validate input
$validOperationTypes = ['bulk_close', 'bulk_assign', 'bulk_priority', 'bulk_status', 'bulk_delete'];
if (!$operationType || !in_array($operationType, $validOperationTypes, true) || empty($ticketIds)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Operation type and ticket IDs required']);
exit;
}
@@ -57,6 +58,7 @@ $ticketIds = array_values(array_filter(array_map(function ($id) {
return (ctype_digit($s) && (int)$s > 0) ? $s : null;
}, $ticketIds)));
if (empty($ticketIds)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'No valid ticket IDs provided']);
exit;
}
+22 -5
View File
@@ -50,12 +50,29 @@ $sql = "SELECT ticket_id, title, status, priority, created_at
$types = "ss" . $visFilter['types'];
$params = array_merge([$searchTerm, $soundexTitle], $visFilter['params']);
$stmt = $conn->prepare($sql);
if (!empty($params)) {
$stmt->bind_param($types, ...$params);
// Duplicate detection is advisory (it must not block ticket creation), so on any
// DB error degrade gracefully to "no duplicates" rather than fataling the request.
// mysqli may throw (default exception mode) or return false depending on config.
try {
$stmt = $conn->prepare($sql);
if (!$stmt) {
throw new RuntimeException('prepare failed: ' . $conn->error);
}
if (!empty($params)) {
$stmt->bind_param($types, ...$params);
}
$stmt->execute();
$result = $stmt->get_result();
if ($result === false) {
// Non-exception mysqli mode: execute/get_result return false instead of
// throwing. Treat as a query failure so we don't fatal on $result below.
throw new RuntimeException('query failed: ' . $conn->error);
}
} catch (Throwable $e) {
error_log('check_duplicates: ' . $e->getMessage());
ResponseHelper::success(['duplicates' => []]);
}
$stmt->execute();
$result = $stmt->get_result();
while ($row = $result->fetch_assoc()) {
// Calculate similarity score
+25
View File
@@ -15,6 +15,7 @@ try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication
if (session_status() === PHP_SESSION_NONE) {
@@ -50,6 +51,8 @@ try {
header('Content-Type: application/json');
$model = new CustomFieldModel($conn);
$auditLog = new AuditLogModel($conn);
$currentUserId = $_SESSION['user']['user_id'];
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
$category = isset($_GET['category']) ? $_GET['category'] : null;
@@ -75,6 +78,13 @@ try {
exit;
}
$result = $model->createDefinition($data);
if (!empty($result['success'])) {
$auditLog->log($currentUserId, 'create', 'custom_field', (string)($result['field_id'] ?? ''), [
'field_name' => $data['field_name'] ?? null,
'field_label' => $data['field_label'] ?? null,
'field_type' => $data['field_type'] ?? null
]);
}
echo json_encode($result);
break;
@@ -92,6 +102,14 @@ try {
exit;
}
$result = $model->updateDefinition($id, $data);
if (!empty($result['success'])) {
$auditLog->log($currentUserId, 'update', 'custom_field', (string)$id, [
'entity' => 'custom_field',
'field_name' => $data['field_name'] ?? null,
'field_label' => $data['field_label'] ?? null,
'field_type' => $data['field_type'] ?? null
]);
}
echo json_encode($result);
break;
@@ -102,7 +120,14 @@ try {
exit;
}
$toDelete = $model->getDefinition($id);
$result = $model->deleteDefinition($id);
if (!empty($result['success'])) {
$auditLog->log($currentUserId, 'delete', 'custom_field', (string)$id, [
'entity' => 'custom_field',
'field_name' => $toDelete['field_name'] ?? 'unknown'
]);
}
echo json_encode($result);
break;
+10 -2
View File
@@ -36,7 +36,11 @@ try {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
ob_end_clean();
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// CSRF Protection
@@ -64,7 +68,11 @@ try {
if (isset($_POST['comment_id'])) {
$data = ['comment_id' => $_POST['comment_id']];
} else {
throw new Exception("Missing required field: comment_id");
ob_end_clean();
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Missing required field: comment_id']);
exit;
}
}
+17 -1
View File
@@ -15,6 +15,22 @@ error_reporting(E_ALL);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
/**
* Neutralize CSV/formula injection: prefix a leading apostrophe to any cell that
* a spreadsheet (Excel/Sheets) would otherwise evaluate as a formula.
*
* @param mixed $value
* @return string
*/
function exportCsvSafeCell($value): string
{
$value = (string)$value;
if ($value !== '' && in_array($value[0], ['=', '+', '-', '@', "\t", "\r"], true)) {
return "'" . $value;
}
return $value;
}
try {
// Include required files
require_once dirname(__DIR__) . '/config/config.php';
@@ -124,7 +140,7 @@ try {
$ticket['updated_at'],
$ticket['description']
];
fputcsv($output, $row);
fputcsv($output, array_map('exportCsvSafeCell', $row));
}
fclose($output);
+27 -6
View File
@@ -24,11 +24,13 @@ try {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
throw new Exception("Authentication required");
}
// Check admin privileges
if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) {
http_response_code(403);
throw new Exception("Admin privileges required");
}
@@ -51,6 +53,7 @@ try {
// Get request data
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
http_response_code(400);
throw new Exception("Invalid request data");
}
@@ -58,10 +61,12 @@ try {
$expiresInDays = $input['expires_in_days'] ?? null;
if (empty($keyName)) {
http_response_code(400);
throw new Exception("Key name is required");
}
if (strlen($keyName) > 100) {
http_response_code(400);
throw new Exception("Key name must be 100 characters or less");
}
@@ -69,6 +74,7 @@ try {
if ($expiresInDays !== null && $expiresInDays !== '') {
$expiresInDays = (int)$expiresInDays;
if ($expiresInDays < 1 || $expiresInDays > 3650) {
http_response_code(400);
throw new Exception("Expiration must be between 1 and 3650 days");
}
} else {
@@ -110,11 +116,26 @@ try {
]);
} catch (Exception $e) {
ob_end_clean();
error_log("Generate API key error: " . $e->getMessage());
header('Content-Type: application/json');
http_response_code(isset($conn) ? 400 : 500);
echo json_encode([
'success' => false,
'error' => 'An internal error occurred'
]);
// Preserve any specific status set before the throw (401/403/400/...);
// only fall back to 500 when nothing more specific was set.
$code = http_response_code();
if (!is_int($code) || $code < 400) {
$code = 500;
}
http_response_code($code);
if ($code >= 500) {
error_log("Generate API key error: " . $e->getMessage());
echo json_encode([
'success' => false,
'error' => 'An internal error occurred'
]);
} else {
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
+43 -1
View File
@@ -95,17 +95,59 @@ if (is_dir($rateLimitDir) && is_writable($rateLimitDir)) {
];
}
// Check 5: Required PHP extensions (catches e.g. a PHP upgrade silently
// dropping php-ldap, which breaks avatars with no other visible error).
$requirements = require dirname(__DIR__) . '/config/requirements.php';
$missingExt = array_values(array_filter(
$requirements['required_extensions'],
fn($ext) => !extension_loaded($ext)
));
if (empty($missingExt)) {
$checks['php_extensions'] = [
'status' => 'ok',
'message' => 'All required extensions loaded'
];
} else {
$checks['php_extensions'] = [
'status' => 'error',
'message' => 'Missing extensions: ' . implode(', ', $missingExt)
];
$healthy = false;
}
// Check 6: PHP version meets the declared minimum
if (version_compare(PHP_VERSION, $requirements['min_php_version'], '>=')) {
$checks['php_version'] = [
'status' => 'ok',
'message' => PHP_VERSION
];
} else {
$checks['php_version'] = [
'status' => 'error',
'message' => sprintf('PHP %s < required %s', PHP_VERSION, $requirements['min_php_version'])
];
$healthy = false;
}
// Calculate response time
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
// Set status code
http_response_code($healthy ? 200 : 503);
// This endpoint is unauthenticated, so expose only a coarse per-component status
// and never the diagnostic messages (they leak PHP_VERSION, exact missing
// extension names, and filesystem paths to anonymous callers).
$publicChecks = [];
foreach ($checks as $name => $check) {
$publicChecks[$name] = ['status' => $check['status']];
}
// Return response
echo json_encode([
'status' => $healthy ? 'healthy' : 'unhealthy',
'timestamp' => date('c'),
'response_time_ms' => $responseTime,
'checks' => $checks,
'checks' => $publicChecks,
'version' => '1.0.0'
], JSON_PRETTY_PRINT);
+123 -26
View File
@@ -15,6 +15,7 @@ try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication
if (session_status() === PHP_SESSION_NONE) {
@@ -52,6 +53,7 @@ try {
header('Content-Type: application/json');
$model = new RecurringTicketModel($conn);
$auditLog = new AuditLogModel($conn);
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
$action = isset($_GET['action']) ? $_GET['action'] : null;
@@ -70,6 +72,12 @@ try {
case 'POST':
if ($action === 'toggle' && $id) {
$result = $model->toggleActive($id);
if (!empty($result['success'])) {
$auditLog->log($currentUserId, 'update', 'recurring_ticket', (string)$id, [
'entity' => 'recurring_ticket',
'action' => 'toggle_active'
]);
}
echo json_encode($result);
} else {
$data = json_decode(file_get_contents('php://input'), true);
@@ -90,6 +98,14 @@ try {
$data['created_by'] = $currentUserId;
$result = $model->create($data);
if (!empty($result['success'])) {
$auditLog->log($currentUserId, 'create', 'recurring_ticket', (string)($result['recurring_id'] ?? ''), [
'title_template' => $data['title_template'],
'schedule_type' => $data['schedule_type'],
'schedule_day' => $data['schedule_day'] ?? null,
'schedule_time' => $data['schedule_time'] ?? '09:00'
]);
}
echo json_encode($result);
}
break;
@@ -106,16 +122,49 @@ try {
exit;
}
// Recalculate next run time if schedule changed
$nextRun = calculateNextRun(
$data['schedule_type'],
$data['schedule_day'] ?? null,
$data['schedule_time'] ?? '09:00'
);
$data['next_run_at'] = $nextRun;
$existing = $model->getById($id);
if (!$existing) {
echo json_encode(['success' => false, 'error' => 'Recurring ticket not found']);
exit;
}
$newDay = $data['schedule_day'] ?? null;
$newTime = $data['schedule_time'] ?? '09:00';
// Only the schedule fields affect when the next occurrence fires.
$scheduleChanged =
(string)$existing['schedule_type'] !== (string)$data['schedule_type']
|| (string)($existing['schedule_day'] ?? '') !== (string)($newDay ?? '')
|| substr((string)$existing['schedule_time'], 0, 5) !== substr((string)$newTime, 0, 5);
$existingNextFuture = !empty($existing['next_run_at'])
&& strtotime($existing['next_run_at']) > time();
// Recompute only when the schedule actually changed (or the stored
// next_run is already in the past). Editing an unrelated field (e.g.
// title) must NOT move next_run_at backwards past an occurrence that
// may already have fired, which would double-create a ticket.
if ($scheduleChanged || !$existingNextFuture) {
$data['next_run_at'] = calculateNextRun(
$data['schedule_type'],
$newDay,
$newTime
);
} else {
$data['next_run_at'] = $existing['next_run_at'];
}
$data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1;
$result = $model->update($id, $data);
if (!empty($result['success'])) {
$auditLog->log($currentUserId, 'update', 'recurring_ticket', (string)$id, [
'entity' => 'recurring_ticket',
'title_template' => $data['title_template'] ?? null,
'schedule_type' => $data['schedule_type'],
'schedule_day' => $newDay,
'schedule_time' => $newTime
]);
}
echo json_encode($result);
break;
@@ -125,7 +174,14 @@ try {
exit;
}
$toDelete = $model->getById($id);
$result = $model->delete($id);
if (!empty($result['success'])) {
$auditLog->log($currentUserId, 'delete', 'recurring_ticket', (string)$id, [
'entity' => 'recurring_ticket',
'title_template' => $toDelete['title_template'] ?? 'unknown'
]);
}
echo json_encode($result);
break;
@@ -139,36 +195,77 @@ try {
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
}
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime)
/**
* Compute the SOONEST FUTURE occurrence matching the schedule.
*
* Returns 'Y-m-d H:i:s' in the app-configured timezone. The current period is
* NOT skipped: a schedule whose time today/this-month is still in the future
* fires then, not one period later.
*
* @param string $scheduleType daily|weekly|monthly
* @param int|null $scheduleDay 1-7 (ISO, 1=Mon..7=Sun) weekly; 1-31 monthly
* @param string $scheduleTime HH:MM or HH:MM:SS
* @param DateTime|null $now Injected "now" for testing
*/
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime, ?DateTime $now = null)
{
$now = new DateTime();
$time = $scheduleTime ?: '09:00';
$tz = new DateTimeZone($GLOBALS['config']['TIMEZONE'] ?? date_default_timezone_get());
$now = $now ? $now : new DateTime('now', $tz);
$parts = explode(':', $scheduleTime ?: '09:00');
$hour = (int)($parts[0] ?? 9);
$minute = (int)($parts[1] ?? 0);
$second = (int)($parts[2] ?? 0);
$next = clone $now;
switch ($scheduleType) {
case 'daily':
$next = new DateTime('tomorrow ' . $time);
break;
case 'weekly':
$days = [1 => 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
$dayName = $days[(int)$scheduleDay] ?? 'Monday';
$next = new DateTime("next {$dayName} " . $time);
$targetDow = (int)$scheduleDay;
if ($targetDow < 1 || $targetDow > 7) {
$targetDow = 1;
}
$next->setTime($hour, $minute, $second);
$currentDow = (int)$next->format('N'); // 1=Mon .. 7=Sun
$daysAhead = ($targetDow - $currentDow + 7) % 7;
// Same weekday but the time already passed today -> next week.
if ($daysAhead === 0 && $next <= $now) {
$daysAhead = 7;
}
if ($daysAhead > 0) {
$next->modify("+{$daysAhead} day");
$next->setTime($hour, $minute, $second);
}
break;
case 'monthly':
$day = max(1, min(31, (int)$scheduleDay));
$next = new DateTime();
$next->modify('first day of next month');
// Clamp to last day of target month (handles Feb, 30-day months)
$daysInMonth = (int)$next->format('t');
$day = min($day, $daysInMonth);
$next->setDate((int)$next->format('Y'), (int)$next->format('m'), $day);
$parts = explode(':', $time . ':00'); // ensure at least H:M
$next->setTime((int)$parts[0], (int)$parts[1], 0);
// This month first, clamped to the month's length (e.g. day 31 -> Feb 28/29).
$daysInMonth = (int)$now->format('t');
$next->setDate((int)$now->format('Y'), (int)$now->format('n'), min($day, $daysInMonth));
$next->setTime($hour, $minute, $second);
if ($next <= $now) {
// Already passed this month -> first day of next month, then clamp.
$firstNext = clone $now;
$firstNext->modify('first day of next month');
$daysInMonth = (int)$firstNext->format('t');
$next->setDate(
(int)$firstNext->format('Y'),
(int)$firstNext->format('n'),
min($day, $daysInMonth)
);
$next->setTime($hour, $minute, $second);
}
break;
case 'daily':
default:
$next = new DateTime('tomorrow ' . $time);
$next->setTime($hour, $minute, $second);
if ($next <= $now) {
$next->modify('+1 day');
$next->setTime($hour, $minute, $second);
}
break;
}
return $next->format('Y-m-d H:i:s');
+33 -3
View File
@@ -14,6 +14,7 @@ RateLimitMiddleware::apply('api');
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication
if (session_status() === PHP_SESSION_NONE) {
@@ -48,6 +49,8 @@ try {
header('Content-Type: application/json');
$auditLog = new AuditLogModel($conn);
$currentUserId = $_SESSION['user']['user_id'];
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
@@ -110,7 +113,13 @@ try {
);
if ($stmt->execute()) {
echo json_encode(['success' => true, 'template_id' => $conn->insert_id]);
$newTemplateId = $conn->insert_id;
$auditLog->log($currentUserId, 'create', 'template', (string)$newTemplateId, [
'template_name' => $templateName,
'category' => $category,
'type' => $type
]);
echo json_encode(['success' => true, 'template_id' => $newTemplateId]);
} else {
error_log("Template creation failed: " . $stmt->error);
echo json_encode(['success' => false, 'error' => 'Failed to create template']);
@@ -161,7 +170,15 @@ try {
$id
);
echo json_encode(['success' => $stmt->execute()]);
$updated = $stmt->execute();
if ($updated) {
$auditLog->log($currentUserId, 'update', 'template', (string)$id, [
'template_name' => $templateName,
'category' => $category,
'type' => $type
]);
}
echo json_encode(['success' => $updated]);
$stmt->close();
break;
@@ -171,9 +188,22 @@ try {
exit;
}
// Capture the name before deletion for the audit record.
$nameStmt = $conn->prepare("SELECT template_name FROM ticket_templates WHERE template_id = ?");
$nameStmt->bind_param('i', $id);
$nameStmt->execute();
$delRow = $nameStmt->get_result()->fetch_assoc();
$nameStmt->close();
$stmt = $conn->prepare("DELETE FROM ticket_templates WHERE template_id = ?");
$stmt->bind_param('i', $id);
echo json_encode(['success' => $stmt->execute()]);
$deleted = $stmt->execute();
if ($deleted) {
$auditLog->log($currentUserId, 'delete', 'template', (string)$id, [
'template_name' => $delRow['template_name'] ?? 'unknown'
]);
}
echo json_encode(['success' => $deleted]);
$stmt->close();
break;
+18
View File
@@ -82,6 +82,15 @@ try {
case 'POST':
$data = json_decode(file_get_contents('php://input'), true);
$wfValid = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
if (
!in_array($data['from_status'] ?? '', $wfValid, true)
|| !in_array($data['to_status'] ?? '', $wfValid, true)
) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'from_status and to_status must be valid ticket statuses']);
exit;
}
if (($data['from_status'] ?? '') === ($data['to_status'] ?? '')) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'From Status and To Status cannot be the same']);
@@ -125,6 +134,15 @@ try {
$data = json_decode(file_get_contents('php://input'), true);
$wfValid = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
if (
!in_array($data['from_status'] ?? '', $wfValid, true)
|| !in_array($data['to_status'] ?? '', $wfValid, true)
) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'from_status and to_status must be valid ticket statuses']);
exit;
}
if (($data['from_status'] ?? '') === ($data['to_status'] ?? '')) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'From Status and To Status cannot be the same']);
+50 -5
View File
@@ -55,13 +55,18 @@ $assignSql = "SELECT
AND al.entity_type = 'ticket'
AND al.user_id != ?
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
AND al.details LIKE ?
AND (al.details LIKE ? OR al.details LIKE ?)
ORDER BY al.created_at DESC
LIMIT 15";
$assignLike = '%"assigned_to":' . $userId . '%';
// Match the exact JSON value with a trailing delimiter so user 12 doesn't also
// match 120/123/etc. Single assigns log {"assigned_to":5} (closing brace) while
// bulk assigns log {"assigned_to":5,"bulk_operation_id":N} (comma) — match both.
$assignId = (int)$userId;
$assignEnd = '%"assigned_to":' . $assignId . '}%';
$assignMid = '%"assigned_to":' . $assignId . ',%';
$stmt = $conn->prepare($assignSql);
$stmt->bind_param('is', $userId, $assignLike);
$stmt->bind_param('iss', $userId, $assignEnd, $assignMid);
$stmt->execute();
$assignRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
@@ -148,10 +153,49 @@ $stmt->execute();
$statusRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
// Query 4: @mentions of me (logged by add_comment.php as
// action_type='mention', entity_type='user', entity_id=<mentioned user_id>).
$mentionSql = "SELECT
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
COALESCE(u.display_name, u.username, 'System') AS actor_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
WHERE al.action_type = 'mention'
AND al.entity_type = 'user'
AND al.entity_id = ?
AND al.user_id != ?
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
ORDER BY al.created_at DESC
LIMIT 15";
$mentionEntityId = (string)$userId;
$stmt = $conn->prepare($mentionSql);
$stmt->bind_param('si', $mentionEntityId, $userId);
$stmt->execute();
$mentionRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
// If the user owns/watches a ticket AND was @mentioned in the same comment, the
// comment query and the mention query both produce a row for it. Prefer the more
// specific mention and drop the duplicate comment notification for that comment.
$mentionCommentIds = [];
foreach ($mentionRows as $mr) {
$md = json_decode($mr['details'] ?? '{}', true) ?? [];
if (!empty($md['comment_id'])) {
$mentionCommentIds[(int)$md['comment_id']] = true;
}
}
if (!empty($mentionCommentIds)) {
$commentRows = array_filter(
$commentRows,
fn($cr) => !isset($mentionCommentIds[(int)($cr['entity_id'] ?? 0)])
);
}
// Merge, deduplicate by log_id, sort by created_at desc
$all = [];
$seen = [];
foreach (array_merge($assignRows, $commentRows, $statusRows) as $row) {
foreach (array_merge($assignRows, $commentRows, $statusRows, $mentionRows) as $row) {
$id = (int)$row['log_id'];
if (isset($seen[$id])) {
continue;
@@ -170,7 +214,7 @@ foreach ($all as $row) {
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
? 'comment'
: $row['action_type'];
$ticketId = ($actionType === 'comment')
$ticketId = ($actionType === 'comment' || $actionType === 'mention')
? ($details['ticket_id'] ?? 0)
: $row['entity_id'];
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
@@ -179,6 +223,7 @@ foreach ($all as $row) {
$title = match ($actionType) {
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
'mention' => "{$row['actor_name']} mentioned you on ticket #{$ticketId}",
'update' => (function () use ($row, $details, $ticketId) {
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}}
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
+28 -6
View File
@@ -24,11 +24,13 @@ try {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
throw new Exception("Authentication required");
}
// Check admin privileges
if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) {
http_response_code(403);
throw new Exception("Admin privileges required");
}
@@ -51,12 +53,14 @@ try {
// Get request data
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
http_response_code(400);
throw new Exception("Invalid request data");
}
$keyId = (int)($input['key_id'] ?? 0);
if ($keyId <= 0) {
http_response_code(400);
throw new Exception("Valid key ID is required");
}
@@ -68,10 +72,12 @@ try {
$keyInfo = $apiKeyModel->getKeyById($keyId);
if (!$keyInfo) {
http_response_code(404);
throw new Exception("API key not found");
}
if (!$keyInfo['is_active']) {
http_response_code(409);
throw new Exception("API key is already revoked");
}
@@ -79,6 +85,7 @@ try {
$success = $apiKeyModel->revokeKey($keyId);
if (!$success) {
http_response_code(500);
throw new Exception("Failed to revoke API key");
}
@@ -103,11 +110,26 @@ try {
]);
} catch (Exception $e) {
ob_end_clean();
error_log("Revoke API key error: " . $e->getMessage());
header('Content-Type: application/json');
http_response_code(isset($conn) ? 400 : 500);
echo json_encode([
'success' => false,
'error' => 'An internal error occurred'
]);
// Preserve any specific status set before the throw (401/403/404/409/...);
// only fall back to 500 when nothing more specific was set.
$code = http_response_code();
if (!is_int($code) || $code < 400) {
$code = 500;
}
http_response_code($code);
if ($code >= 500) {
error_log("Revoke API key error: " . $e->getMessage());
echo json_encode([
'success' => false,
'error' => 'An internal error occurred'
]);
} else {
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
+21 -9
View File
@@ -27,10 +27,19 @@ register_shutdown_function(function () {
ini_set('display_errors', 0);
error_reporting(E_ALL);
// Custom error handler
// Custom error handler. Only genuine errors abort the request; notices,
// warnings and deprecations (e.g. new deprecations on a PHP upgrade) are
// logged but must not take the endpoint down with a 500.
set_error_handler(function ($errno, $errstr, $errfile, $errline) {
// Log detailed error server-side
// Respect the @-operator / error_reporting.
if (!(error_reporting() & $errno)) {
return false;
}
error_log("PHP Error in ticket_dependencies.php: $errstr in $errfile:$errline");
if (!in_array($errno, [E_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR, E_PARSE], true)) {
// Non-fatal: log and continue.
return true;
}
ob_end_clean();
http_response_code(500);
header('Content-Type: application/json');
@@ -80,6 +89,9 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
$userId = $_SESSION['user']['user_id'];
$currentUser = $_SESSION['user'];
$isAdmin = $currentUser['is_admin'] ?? false;
// users.groups is a comma-separated string; the dependency model expects an array.
$userGroups = array_values(array_filter(array_map('trim', explode(',', $currentUser['groups'] ?? ''))));
// CSRF Protection for POST/DELETE
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
@@ -121,14 +133,14 @@ try {
}
// Verify user can access this ticket
$ticket = $ticketModel->getTicketById((int)$ticketId);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
try {
$dependencies = $dependencyModel->getDependencies($ticketId);
$dependents = $dependencyModel->getDependentTickets($ticketId);
$dependencies = $dependencyModel->getDependencies($ticketId, $userId, $userGroups, $isAdmin);
$dependents = $dependencyModel->getDependentTickets($ticketId, $userId, $userGroups, $isAdmin);
} catch (Exception $e) {
error_log('Query error in ticket_dependencies.php GET: ' . $e->getMessage());
ResponseHelper::serverError('Failed to retrieve dependencies');
@@ -157,11 +169,11 @@ try {
}
// Verify user can access both tickets before creating dependency
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
$srcTicket = $ticketModel->getTicketById($ticketId);
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
$tgtTicket = $ticketModel->getTicketById((int)$dependsOnId);
$tgtTicket = $ticketModel->getTicketById($dependsOnId);
if (!$tgtTicket || !$ticketModel->canUserAccessTicket($tgtTicket, $currentUser)) {
ResponseHelper::notFound('Target ticket not found');
}
@@ -205,7 +217,7 @@ try {
}
// Verify user can access the source ticket
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
$srcTicket = $ticketModel->getTicketById($ticketId);
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
@@ -235,7 +247,7 @@ try {
ResponseHelper::notFound('Dependency not found');
}
$depTicket = $ticketModel->getTicketById((int)$depRow['ticket_id']);
$depTicket = $ticketModel->getTicketById($depRow['ticket_id']);
if (!$depTicket || !$ticketModel->canUserAccessTicket($depTicket, $currentUser)) {
ResponseHelper::forbidden('Access denied');
}
+17 -5
View File
@@ -27,12 +27,16 @@ try {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
ob_end_clean();
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// CSRF Protection
// CSRF Protection for all state-changing methods (any non-GET/HEAD request)
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT') {
if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
@@ -53,7 +57,11 @@ try {
$data = json_decode(file_get_contents('php://input'), true);
if (!$data || !isset($data['comment_id']) || !isset($data['comment_text'])) {
throw new Exception("Missing required fields: comment_id, comment_text");
ob_end_clean();
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Missing required fields: comment_id, comment_text']);
exit;
}
$commentId = (int)$data['comment_id'];
@@ -61,7 +69,11 @@ try {
$markdownEnabled = isset($data['markdown_enabled']) && $data['markdown_enabled'];
if (empty($commentText)) {
throw new Exception("Comment text cannot be empty");
ob_end_clean();
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Comment text cannot be empty']);
exit;
}
// Initialize models
+81 -22
View File
@@ -34,7 +34,11 @@ try {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
ob_end_clean();
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// CSRF Protection
@@ -44,9 +48,14 @@ try {
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode([
'success' => false,
'error' => 'Invalid CSRF token',
'csrf_token' => CsrfMiddleware::getToken()
]);
exit;
}
$GLOBALS['newCsrfToken'] = CsrfMiddleware::rotateToken();
}
$currentUser = $_SESSION['user'];
@@ -115,7 +124,8 @@ try {
if (empty($updateData['title'])) {
return [
'success' => false,
'error' => 'Title cannot be empty'
'error' => 'Title cannot be empty',
'http_status' => 400
];
}
@@ -123,10 +133,51 @@ try {
if ($updateData['priority'] < 1 || $updateData['priority'] > 5) {
return [
'success' => false,
'error' => 'Priority must be between 1 and 5'
'error' => 'Priority must be between 1 and 5',
'http_status' => 400
];
}
// Validate visibility BEFORE any DB write so a bad payload can't leave the
// ticket half-updated (core fields committed but request reported as failed).
$visibilityGroups = null;
if (isset($data['visibility'])) {
$visibilityGroups = $data['visibility_groups'] ?? null;
// Convert array to comma-separated string if needed
if (is_array($visibilityGroups)) {
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
}
// Authorization: only an admin or the ticket's creator may change
// visibility. Enforce only when the requested visibility actually
// differs so ordinary edits that re-send the same value aren't blocked.
$currentVisibility = $currentTicket['visibility'] ?? 'public';
$currentGroups = $currentTicket['visibility_groups'] ?? null;
$groupsProvided = array_key_exists('visibility_groups', $data);
$visibilityChanged = ($data['visibility'] !== $currentVisibility)
|| ($groupsProvided && (string)$visibilityGroups !== (string)$currentGroups);
if ($visibilityChanged) {
$isCreator = $this->userId !== null
&& (int)($currentTicket['created_by'] ?? 0) === (int)$this->userId;
if (!$this->isAdmin && !$isCreator) {
return [
'success' => false,
'error' => 'You do not have permission to change ticket visibility',
'http_status' => 403
];
}
}
// Internal visibility requires at least one group
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
return [
'success' => false,
'error' => 'Internal visibility requires at least one group to be specified',
'http_status' => 400
];
}
}
// Validate status transition using workflow model
if ($currentTicket['status'] !== $updateData['status']) {
$allowed = $this->workflowModel->isTransitionAllowed(
@@ -141,6 +192,19 @@ try {
'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status']
];
}
// Enforce requires_comment transitions server-side.
if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) {
$comment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
if ($comment === '') {
return [
'success' => false,
'error' => 'A comment is required for this status change',
'requires_comment' => true,
'http_status' => 400
];
}
}
}
// Update ticket with user tracking and optional optimistic locking
@@ -160,22 +224,8 @@ try {
return $response;
}
// Handle visibility update if provided
// Handle visibility update if provided (already validated above)
if (isset($data['visibility'])) {
$visibilityGroups = $data['visibility_groups'] ?? null;
// Convert array to comma-separated string if needed
if (is_array($visibilityGroups)) {
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
}
// Validate internal visibility requires groups
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
return [
'success' => false,
'error' => 'Internal visibility requires at least one group to be specified'
];
}
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
if ($visResult && $this->userId) {
$this->auditLog->log(
@@ -234,7 +284,8 @@ try {
'status' => $updateData['status'],
'priority' => $updateData['priority'],
'updated_at' => date('Y-m-d H:i:s'),
'message' => 'Ticket updated successfully'
'message' => 'Ticket updated successfully',
'csrf_token' => $GLOBALS['newCsrfToken'] ?? null
];
}
}
@@ -252,11 +303,19 @@ try {
$data = json_decode($input, true);
if (!$data) {
throw new Exception("Invalid JSON data received: " . $input);
ob_end_clean();
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid JSON data received']);
exit;
}
if (!isset($data['ticket_id'])) {
throw new Exception("Missing ticket_id parameter");
ob_end_clean();
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Missing ticket_id parameter']);
exit;
}
$ticketId = trim((string)$data['ticket_id']);
+13 -4
View File
@@ -110,6 +110,7 @@ $safeUsername = ldap_escape($username, '', LDAP_ESCAPE_FILTER);
$filter = "(uid=$safeUsername)";
$avatarData = null;
$ldapQueryOk = false; // true only if the LDAP lookup completed without error
try {
$ldap = @ldap_connect("ldap://$ldapHost:$ldapPort");
@@ -137,20 +138,28 @@ try {
$avatarData = $entries[0]['avatar'][0];
}
// The query ran to completion — any "no avatar" result is authoritative.
$ldapQueryOk = true;
ldap_unbind($ldap);
} catch (Exception $e) {
error_log("user_avatar: LDAP error for username=$username: " . $e->getMessage());
// Fall through to 404
// Transient LDAP failure: do NOT poison the negative cache. Fall through to
// a plain 404 so the avatar is retried on the next request once LDAP recovers.
}
if ($avatarData === null || strlen($avatarData) < 100) {
// Write sentinel so we don't hammer LDAP for users without avatars
file_put_contents($noAvatarSentinel, '');
// Only cache "no avatar" when LDAP actually answered. On an error/timeout we
// leave no sentinel, so the lookup is retried instead of being stuck for the TTL.
if ($ldapQueryOk) {
file_put_contents($noAvatarSentinel, '');
}
http_response_code(404);
exit;
}
// Validate it's actually a JPEG (magic bytes FF D8 FF)
// Validate it's actually a JPEG (magic bytes FF D8 FF). A successful LDAP read of
// non-JPEG data is a genuine "no usable avatar", so the sentinel is appropriate here.
if (substr($avatarData, 0, 3) !== "\xFF\xD8\xFF") {
error_log("user_avatar: non-JPEG data for username=$username");
file_put_contents($noAvatarSentinel, '');
+21 -3
View File
@@ -10,12 +10,13 @@
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$ticketId = isset($_GET['ticket_id'])
? (int)$_GET['ticket_id']
: (isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0);
: (int)($data['ticket_id'] ?? 0);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$ticketId = (int)($data['ticket_id'] ?? 0);
$action = $data['action'] ?? '';
@@ -78,6 +79,17 @@ if ($ticketId <= 0) {
exit;
}
// Enforce ticket visibility before returning watch state / watcher names, so a
// restricted ticket's watcher list and count aren't disclosed (the POST path
// already checks this).
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
$watchingStmt = $conn->prepare(
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
);
@@ -103,7 +115,13 @@ while ($row = $watchersResult->fetch_assoc()) {
$watchers[] = ['user_id' => (int)$row['user_id'], 'display_name' => $row['display_name']];
}
$watchersStmt->close();
$count = count($watchers);
// True watcher count (the list above is capped at 6 for the avatar group)
$countStmt = $conn->prepare("SELECT COUNT(*) AS cnt FROM ticket_watchers WHERE ticket_id = ?");
$countStmt->bind_param("i", $ticketId);
$countStmt->execute();
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
$countStmt->close();
echo json_encode([
'success' => true,
+81 -5
View File
@@ -2458,7 +2458,7 @@ select option:checked {
}
.lt-progress-bar {
height: 100%;
background: var(--accent-orange);
background: linear-gradient(90deg, var(--accent-orange), #ff8c2b);
box-shadow: var(--glow-orange);
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
@@ -2471,9 +2471,9 @@ select option:checked {
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4));
}
.lt-progress--cyan .lt-progress-bar { background: var(--accent-cyan); box-shadow: var(--glow-cyan); }
.lt-progress--green .lt-progress-bar { background: var(--accent-green); box-shadow: var(--glow-green); }
.lt-progress--red .lt-progress-bar { background: var(--accent-red); box-shadow: var(--glow-red); }
.lt-progress--cyan .lt-progress-bar { background: linear-gradient(90deg, var(--accent-cyan), #33dfff); box-shadow: var(--glow-cyan); }
.lt-progress--green .lt-progress-bar { background: linear-gradient(90deg, var(--accent-green), #33ffaa); box-shadow: var(--glow-green); }
.lt-progress--red .lt-progress-bar { background: linear-gradient(90deg, var(--accent-red), #ff4466); box-shadow: var(--glow-red); }
.lt-progress--striped .lt-progress-bar {
background-image: repeating-linear-gradient(
45deg, transparent, transparent 4px,
@@ -4479,7 +4479,83 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
/* ----------------------------------------------------------------
61. TIMELINE / ACTIVITY FEED
61. SLA BANNER
----------------------------------------------------------------
lt-sla-p1 — pulsing red banner for critical SLA breach
lt-sla-p2 — static amber banner for high-priority SLA warning
---------------------------------------------------------------- */
.lt-sla-p1,
.lt-sla-p2 {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
border: 1px solid;
font-family: var(--font-mono);
}
.lt-sla-p1 {
border-color: rgba(255,45,85,0.4);
background: rgba(255,45,85,0.08);
animation: lt-sla-pulse 2s infinite;
}
.lt-sla-p2 {
border-color: rgba(255,179,0,0.4);
background: rgba(255,179,0,0.08);
}
@keyframes lt-sla-pulse {
0%, 100% { box-shadow: 0 0 8px rgba(255,45,85,0.20); }
50% { box-shadow: 0 0 20px rgba(255,45,85,0.45); }
}
.lt-sla-icon { font-size: 1rem; flex-shrink: 0; }
.lt-sla-info { flex: 1; min-width: 0; }
.lt-sla-title {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
margin-bottom: 4px;
}
.lt-sla-p1 .lt-sla-title { color: var(--accent-red); text-shadow: var(--glow-red); }
.lt-sla-p2 .lt-sla-title { color: var(--accent-amber); text-shadow: var(--glow-amber); }
.lt-sla-bar {
height: 5px;
background: rgba(255,255,255,0.08);
position: relative;
overflow: hidden;
}
.lt-sla-fill {
height: 100%;
width: 0%;
transition: width 0.4s ease;
}
.lt-sla-p1 .lt-sla-fill { background: linear-gradient(90deg, var(--accent-red), var(--accent-orange)); box-shadow: 0 0 8px rgba(255,45,85,0.6); }
.lt-sla-p2 .lt-sla-fill { background: linear-gradient(90deg, var(--accent-amber), #ffd740); box-shadow: 0 0 8px rgba(255,179,0,0.6); }
.lt-sla-meta {
font-size: 0.60rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.10em;
flex-shrink: 0;
}
.lt-sla-dismiss {
font-size: 0.70rem;
color: var(--text-dim);
cursor: pointer;
background: none;
border: none;
flex-shrink: 0;
padding: 0 0.25rem;
font-family: var(--font-mono);
transition: color 0.15s ease;
}
.lt-sla-dismiss:hover { color: var(--text-secondary); }
.lt-sla-dismiss:focus-visible { outline: 1px dashed var(--accent-cyan); outline-offset: 2px; }
html[data-theme="light"] .lt-sla-p1 { background: rgba(180,30,50,0.06); border-color: rgba(180,30,50,0.35); }
html[data-theme="light"] .lt-sla-p2 { background: rgba(138,90,0,0.06); border-color: rgba(138,90,0,0.35); }
/* ----------------------------------------------------------------
62. TIMELINE / ACTIVITY FEED
---------------------------------------------------------------- */
.lt-timeline {
display: flex;
+102 -6
View File
@@ -468,7 +468,15 @@
try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.message); }
let data;
try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; }
if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status);
// Resync CSRF token from any response body that carries a fresh one
// (bootstrap rotates on success and returns the current token on rejection).
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
if (!resp.ok) {
const err = new Error(data.error || data.message || 'HTTP ' + resp.status);
err.data = data;
err.status = resp.status;
throw err;
}
return data;
}
@@ -2004,6 +2012,7 @@
let _focusedIdx = -1;
let _items = [];
let _debTimer = null;
let _searchSeq = 0;
function _render(items, query) {
_items = items.slice(0, maxResults);
@@ -2028,16 +2037,21 @@
}
async function _search(query) {
// Sequence guard: only the latest query is allowed to render, so a slow
// earlier async source() cannot overwrite a newer query's results.
const seq = ++_searchSeq;
dropdown.innerHTML = '<div class="lt-typeahead-loading">Searching…</div>';
dropdown.classList.add('is-open');
inputEl.setAttribute('aria-busy', 'true');
try {
const results = typeof source === 'function' ? await source(query) : source.filter(i => i.label.toLowerCase().includes(query.toLowerCase()));
if (seq !== _searchSeq) return;
_render(results, query);
} catch(e) {
if (seq !== _searchSeq) return;
dropdown.innerHTML = '<div class="lt-typeahead-empty">Error loading results</div>';
} finally {
inputEl.setAttribute('aria-busy', 'false');
if (seq === _searchSeq) inputEl.setAttribute('aria-busy', 'false');
}
}
@@ -2704,7 +2718,15 @@
}
let data;
try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; }
if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status);
// Resync CSRF token from any response body that carries a fresh one
// (bootstrap rotates on success and returns the current token on rejection).
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
if (!resp.ok) {
const err = new Error(data.error || data.message || 'HTTP ' + resp.status);
err.data = data;
err.status = resp.status;
throw err;
}
return data;
}
api.get = url => _apiFetchAuth('GET', url);
@@ -2713,6 +2735,79 @@
api.patch = (u, b) => _apiFetchAuth('PATCH', u, b);
api.delete = (u, b) => _apiFetchAuth('DELETE', u, b);
/* ================================================================
TICKET STATUS CHANGE (comment-aware)
lt.ticketStatus.submit(ticketId, newStatus, { comment? }) → Promise<data>
Posts /api/update_ticket.php. If the server rejects with
requires_comment, opens a comment modal, persists the comment via
/api/add_comment.php, then retries the update once WITH the comment.
Rejects with err.cancelled === true if the user cancels the modal.
================================================================ */
function _statusCommentModal(newStatus) {
return new Promise(resolve => {
const modalId = 'ltStatusCommentModal' + Date.now();
const safeStatus = escHtml(newStatus);
document.body.insertAdjacentHTML('beforeend',
'<div class="lt-modal-overlay" id="' + modalId + '" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="' + modalId + '_title">' +
'<div class="lt-modal lt-modal-sm">' +
'<div class="lt-modal-header" style="color:var(--terminal-amber)">' +
'<span class="lt-modal-title" id="' + modalId + '_title">[ ! ] Change Status to ' + safeStatus + '</span>' +
'<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>' +
'</div>' +
'<div class="lt-modal-body">' +
'<p class="lt-text-sm lt-text-muted" style="margin-bottom:0.6rem">A comment is required when changing status to <strong>' + safeStatus + '</strong>. Enter your reason below.</p>' +
'<textarea id="' + modalId + '_comment" class="lt-input lt-w-full" rows="3" placeholder="Reason for status change…" style="resize:vertical;font-family:inherit;font-size:0.8rem" aria-label="Required comment for status change"></textarea>' +
'</div>' +
'<div class="lt-modal-footer">' +
'<button class="lt-btn lt-btn-primary" id="' + modalId + '_confirm">CONFIRM CHANGE</button>' +
'<button class="lt-btn lt-btn-ghost" id="' + modalId + '_cancel">CANCEL</button>' +
'</div>' +
'</div>' +
'</div>');
const modalEl = document.getElementById(modalId);
openModal(modalId);
let done = false;
const finish = (value) => {
if (done) return;
done = true;
closeModal(modalId);
setTimeout(() => { if (modalEl && modalEl.parentNode) modalEl.remove(); }, 300);
resolve(value);
};
modalEl.querySelector('[data-modal-close]').addEventListener('click', () => finish(null));
document.getElementById(modalId + '_cancel').addEventListener('click', () => finish(null));
document.getElementById(modalId + '_confirm').addEventListener('click', () => {
const ta = document.getElementById(modalId + '_comment');
const comment = ta ? ta.value.trim() : '';
if (!comment) { if (ta) ta.focus(); toast.warning('Please enter a reason for this status change.'); return; }
finish(comment);
});
setTimeout(() => { const ta = document.getElementById(modalId + '_comment'); if (ta) ta.focus(); }, 100);
});
}
const ticketStatus = {
submit(ticketId, newStatus, opts) {
opts = opts || {};
const id = String(ticketId);
const payload = { ticket_id: id, status: newStatus };
if (opts.comment) payload.comment = opts.comment;
return api.post('/api/update_ticket.php', payload).catch(err => {
if (!(err && err.data && err.data.requires_comment)) throw err;
return _statusCommentModal(newStatus).then(comment => {
if (!comment) {
const cancelErr = new Error('Status change cancelled');
cancelErr.cancelled = true;
throw cancelErr;
}
// Persist the comment, then retry the status change with it included.
return api.post('/api/add_comment.php', { ticket_id: id, comment_text: comment })
.then(() => api.post('/api/update_ticket.php', { ticket_id: id, status: newStatus, comment: comment }));
});
});
},
};
/* ================================================================
MODULE 54 — MARKDOWN RENDERER
lt.markdown.render(mdString) → HTML string (sanitized)
@@ -2722,9 +2817,9 @@
================================================================ */
const markdown = {
render(md) {
// Delegate to window.marked if available
if (global.marked) return global.marked.parse(md);
if (global.markdownit) return global.markdownit().render(md);
// Always use the built-in XSS-safe micro-renderer. Do NOT delegate to
// window.marked / window.markdownit: their raw HTML output is not sanitized
// here, so delegating would enable stored XSS if such a lib were ever loaded.
// Micro-renderer: covers headings, bold, italic, code, links, lists, blockquote, hr
let html = escHtml(md)
// Fenced code blocks
@@ -2943,6 +3038,7 @@
lightbox,
auth,
markdown,
ticketStatus,
pagination,
sidebarSubmenus: { init: initSidebarSubmenus },
};
+45 -33
View File
@@ -1000,18 +1000,20 @@ function performQuickStatusChange(ticketId) {
if (!quickStatusEl) return;
const newStatus = quickStatusEl.value;
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus })
// Close this modal first so the comment modal (if requires_comment) stacks cleanly.
closeQuickStatusModal();
lt.ticketStatus.submit(ticketId, newStatus)
.then(data => {
closeQuickStatusModal();
if (data.success) {
if (data && data.success) {
lt.toast.success(`Status updated to ${newStatus}`, 3000);
showTableSkeleton(5); setTimeout(() => window.location.reload(), 1000);
} else {
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
lt.toast.error('Error: ' + ((data && data.error) || 'Unknown error'), 4000);
}
})
.catch(error => {
closeQuickStatusModal();
if (error && error.cancelled) return;
lt.toast.error('Error updating status', 4000);
});
}
@@ -1142,8 +1144,10 @@ function populateKanbanCards() {
if (cells.length < 6) return;
const ticketId = cells[0 + offset]?.querySelector('.ticket-link')?.textContent.trim() || '';
const priorityEl = cells[1 + offset]?.querySelector('[class*="lt-p"]');
const priority = priorityEl ? priorityEl.textContent.trim().replace('P','') : cells[1 + offset]?.textContent.trim() || '4';
// The priority cell renders a "P1".."P5" badge; extract just the digit.
// (The old [class*="lt-p"] selector never matched the lt-badge-p1 class, so
// every card fell back to P4 regardless of real priority.)
const priority = (cells[1 + offset]?.textContent.trim() || '').replace(/[^0-9]/g, '') || '4';
const title = cells[2 + offset]?.textContent.trim() || '';
const category = cells[3 + offset]?.textContent.trim() || '';
const statusEl = cells[5 + offset]?.querySelector('.lt-status');
@@ -1166,8 +1170,9 @@ function populateKanbanCards() {
card.dataset.ticketId = ticketId;
card.dataset.status = status;
card.addEventListener('click', (e) => {
// Don't navigate if drag just ended (drag adds/removes is-dragging briefly)
if (card.dataset.dragged) { delete card.dataset.dragged; return; }
// Don't navigate if a drag just ended. The flag is cleared on a timer
// (see handleKanbanSort), so a genuine later click is not swallowed.
if (card.dataset.dragged) return;
window.location.href = '/ticket/' + encodeURIComponent(ticketId);
});
card.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') card.click(); };
@@ -1212,6 +1217,9 @@ function populateKanbanCards() {
movedCard.dataset.status = newStatus;
movedCard.dataset.dragged = '1';
// Clear the drag flag shortly after the drop so it suppresses only the
// synthetic click fired on drop, not the user's next genuine click.
setTimeout(function () { delete movedCard.dataset.dragged; }, 400);
// Optimistically update column counts
const dec = document.querySelector(`.column-count[data-status="${oldStatus}"]`);
@@ -1219,29 +1227,31 @@ function populateKanbanCards() {
if (dec) dec.textContent = '(' + Math.max(0, (parseInt(dec.textContent.replace(/\D/g,''),10)||1) - 1) + ')';
if (inc) inc.textContent = '(' + ((parseInt(inc.textContent.replace(/\D/g,''),10)||0) + 1) + ')';
// POST status update
fetch('/api/update_ticket.php', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN || '' },
body: JSON.stringify({ ticket_id: String(ticketId), status: newStatus })
})
.then(r => r.json())
.then(data => {
if (data.success) {
lt.toast.success('Ticket #' + ticketId + ' → ' + newStatus, 2500);
movedCard.dataset.status = newStatus;
} else {
lt.toast.error('Status update failed: ' + (data.error || 'Unknown error'));
// Revert: put card back in original column
const origCol = document.getElementById(Object.keys(colStatusMap).find(k => colStatusMap[k] === oldStatus));
if (origCol) origCol.appendChild(movedCard);
movedCard.dataset.status = oldStatus;
}
})
.catch(() => {
lt.toast.error('Network error — status not saved');
});
// Revert the card to its original column and undo the optimistic counts.
const revert = function () {
const origCol = document.getElementById(Object.keys(colStatusMap).find(k => colStatusMap[k] === oldStatus));
if (origCol) origCol.appendChild(movedCard);
movedCard.dataset.status = oldStatus;
if (dec) dec.textContent = '(' + ((parseInt(dec.textContent.replace(/\D/g, ''), 10) || 0) + 1) + ')';
if (inc) inc.textContent = '(' + Math.max(0, (parseInt(inc.textContent.replace(/\D/g, ''), 10) || 1) - 1) + ')';
};
// Submit via the shared comment-aware helper. Dropping to Closed (or
// reopening) prompts for a required comment and retries; cancel reverts.
lt.ticketStatus.submit(String(ticketId), newStatus)
.then(function (data) {
if (data && data.success) {
lt.toast.success('Ticket #' + ticketId + ' → ' + newStatus, 2500);
movedCard.dataset.status = newStatus;
} else {
lt.toast.error('Status update failed: ' + ((data && data.error) || 'Unknown error'));
revert();
}
})
.catch(function (error) {
if (!(error && error.cancelled)) lt.toast.error('Status update failed — reverting');
revert();
});
}
Object.keys(columns).forEach(status => {
@@ -1314,7 +1324,9 @@ function showTicketPreview(event) {
const offset = isAdmin ? 1 : 0;
const ticketId = link.textContent.trim();
const priority = cells[1 + offset]?.textContent.trim() || '';
// Cell text is already "P1".."P5"; strip the leading P so the template's
// `P${priority}` doesn't render "PP1".
const priority = (cells[1 + offset]?.textContent.trim() || '').replace(/^P/i, '');
const title = cells[2 + offset]?.textContent.trim() || '';
const category = cells[3 + offset]?.textContent.trim() || '';
const type = cells[4 + offset]?.textContent.trim() || '';
+19 -5
View File
@@ -6,11 +6,27 @@
// Track currently selected row for J/K navigation
let currentSelectedRowIndex = -1;
let lastNavRowCount = -1;
// Only navigate real, visible rows — skip skeleton placeholders and rows hidden
// by filters/column toggles (offsetParent is null when display:none).
function getNavigableRows() {
return Array.from(document.querySelectorAll('tbody tr')).filter(function(row) {
return !row.classList.contains('lt-skeleton-row') && row.offsetParent !== null;
});
}
function navigateTableRow(direction) {
const rows = document.querySelectorAll('tbody tr');
const rows = getNavigableRows();
if (rows.length === 0) return;
// Reset the index when the row set changes (e.g. filter/reload) so navigation
// never lands on a stale/hidden index.
if (rows.length !== lastNavRowCount) {
currentSelectedRowIndex = -1;
lastNavRowCount = rows.length;
}
rows.forEach(row => row.classList.remove('keyboard-selected'));
if (direction === 'next') {
@@ -47,10 +63,8 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
// ?: Show keyboard shortcuts help — use the static #lt-keys-help modal in the footer
lt.keys.on('?', function() {
if (window.lt) lt.modal.open('lt-keys-help');
});
// Note: the '?' help shortcut is registered by lt.keys.initDefaults(); do not
// re-bind it here or the help modal opens twice.
// J: Next row
lt.keys.on('j', () => navigateTableRow('next'));
+67 -26
View File
@@ -6,6 +6,13 @@
function parseMarkdown(markdown) {
if (!markdown) return '';
// Footnote labels are captured before the HTML-escape pass, so they must be
// sanitized to a safe slug before being interpolated into id/href attributes
// (otherwise a label like `x"><img onerror=...>` breaks out → stored XSS).
var fnSlug = function (label) {
return String(label).replace(/[^a-zA-Z0-9_-]/g, '-');
};
// Footnotes — collect definitions and mark references with placeholders
// (must happen before HTML escaping so <sup> tags don't get escaped)
const footnotes = {};
@@ -25,18 +32,31 @@ function parseMarkdown(markdown) {
let html = markdown;
// Escape HTML first to prevent XSS
// Escape HTML first to prevent XSS. Quotes MUST be escaped too: user-controlled
// text (e.g. image/link URLs and alt text) is later interpolated into "..."
// attributes, so an unescaped " would break out and inject event handlers.
html = html.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
// Ticket references (#123456789) - convert to clickable links
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
// Code blocks (```code```) - preserve content and don't process further
// Code blocks (```lang\ncode\n```) - preserve content and don't process further
const codeBlocks = [];
html = html.replace(/```([\s\S]*?)```/g, function(match, code) {
codeBlocks.push('<pre class="code-block"><code>' + code + '</code></pre>');
html = html.replace(/```([a-zA-Z0-9_+-]*)\n?([\s\S]*?)```/g, function(match, lang, code) {
lang = lang ? lang.trim() : '';
const displayLang = lang || 'text';
// Build header with optional copy button if one exists in your UI, otherwise just lang
const header = '<div class="lt-code-header"><span class="lt-code-lang">' + displayLang + '</span></div>';
// Remove exactly one trailing newline from code block if it exists
if (code.endsWith('\n')) {
code = code.slice(0, -1);
}
// Wrap in the specific UI classes expected by base.css
codeBlocks.push('<div class="lt-code-block">' + header + '<pre><code>' + code + '</code></pre></div>');
return '%%CODEBLOCK' + (codeBlocks.length - 1) + '%%';
});
@@ -47,6 +67,11 @@ function parseMarkdown(markdown) {
return '%%INLINECODE' + (inlineCodes.length - 1) + '%%';
});
// Ticket references (#123456789) - convert to clickable links.
// Runs AFTER code extraction so a literal #123456789 inside inline/fenced code
// (now replaced by a placeholder) is not turned into a link.
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
// Tables (must be processed before other block elements)
html = parseMarkdownTables(html);
@@ -131,18 +156,20 @@ function parseMarkdown(markdown) {
html = html.replace(/ \n/g, '<br>');
html = html.replace(/\n\n/g, '</p><p>');
// Restore code blocks and inline code
// Restore code blocks and inline code. Use a function replacer so '$'
// sequences in user code (e.g. $&, $$, $`, $') are inserted literally rather
// than interpreted as String.replace replacement patterns.
codeBlocks.forEach((block, i) => {
html = html.replace('%%CODEBLOCK' + i + '%%', block);
html = html.replace('%%CODEBLOCK' + i + '%%', () => block);
});
inlineCodes.forEach((code, i) => {
html = html.replace('%%INLINECODE' + i + '%%', code);
html = html.replace('%%INLINECODE' + i + '%%', () => code);
});
// Restore footnote reference placeholders
fnRefs.forEach(function(ref, i) {
html = html.replace('%%FNREF' + i + '%%',
'<sup class="fn-ref"><a href="#fn-' + ref.label + '" id="fnref-' + ref.label + '">[' + ref.n + ']</a></sup>');
'<sup class="fn-ref"><a href="#fn-' + fnSlug(ref.label) + '" id="fnref-' + fnSlug(ref.label) + '">[' + ref.n + ']</a></sup>');
});
// Wrap in paragraph if not already wrapped
@@ -153,10 +180,10 @@ function parseMarkdown(markdown) {
// Append footnote definitions block
if (footnoteOrder.length) {
html += '<hr class="fn-hr"><ol class="fn-list">';
footnoteOrder.forEach(function(label, i) {
html += '<li id="fn-' + label + '" class="fn-item">' +
footnoteOrder.forEach(function(label) {
html += '<li id="fn-' + fnSlug(label) + '" class="fn-item">' +
parseMarkdown(footnotes[label]).replace(/<\/?p>/g, '') +
' <a href="#fnref-' + label + '" class="fn-back">&#x21A9;</a></li>';
' <a href="#fnref-' + fnSlug(label) + '" class="fn-back">&#x21A9;</a></li>';
});
html += '</ol>';
}
@@ -274,33 +301,45 @@ function buildTable(rows) {
if (rows.length === 0) return '';
let html = '<table class="markdown-table">';
let inThead = false;
let inTbody = false;
rows.forEach((row, index) => {
rows.forEach((row) => {
const cells = row.content.split('|').filter(cell => cell.trim() !== '');
const tag = row.type === 'header' ? 'th' : 'td';
const wrapper = row.type === 'header' ? 'thead' : (index === 1 ? 'tbody' : '');
const isHeader = row.type === 'header';
const tag = isHeader ? 'th' : 'td';
if (wrapper === 'thead') html += '<thead>';
if (wrapper === 'tbody') html += '<tbody>';
if (isHeader && !inThead) { html += '<thead>'; inThead = true; }
if (!isHeader && !inTbody) {
if (inThead) { html += '</thead>'; inThead = false; }
html += '<tbody>';
inTbody = true;
}
html += '<tr>';
cells.forEach(cell => {
html += `<${tag}>${cell.trim()}</${tag}>`;
});
html += '</tr>';
if (row.type === 'header') html += '</thead>';
});
html += '</tbody></table>';
// Close whichever section is still open so tags are balanced for header-only,
// body-only, and header+body tables alike.
if (inThead) html += '</thead>';
if (inTbody) html += '</tbody>';
html += '</table>';
return html;
}
// Apply markdown rendering to all elements with data-markdown attribute
function renderMarkdownElements() {
document.querySelectorAll('[data-markdown]').forEach(element => {
const markdownText = element.getAttribute('data-markdown') || element.textContent;
document.querySelectorAll('[data-markdown]:not([data-rendered])').forEach(element => {
// Trim so template indentation/whitespace in the element's text content
// doesn't get parsed as a leading code block (which breaks headings,
// tables, etc. and diverges from the live preview).
const markdownText = (element.getAttribute('data-markdown') || element.textContent).trim();
element.innerHTML = parseMarkdown(markdownText);
element.dataset.rendered = '1';
});
}
@@ -551,7 +590,9 @@ function processPlainTextComments() {
function renderMarkdownComments() {
document.querySelectorAll('.comment-text[data-markdown]:not([data-rendered])').forEach(el => {
el.classList.add('lt-markdown');
el.innerHTML = parseMarkdown(el.textContent);
// Trim template whitespace so the first line isn't parsed as an
// indented code block (matches the live-preview rendering).
el.innerHTML = parseMarkdown(el.textContent.trim());
el.dataset.rendered = '1';
});
}
+40 -23
View File
@@ -291,14 +291,8 @@ function addComment() {
// For markdown, use parseMarkdown (sanitizes HTML)
displayText = parseMarkdown(commentText);
} else {
// For non-markdown, convert line breaks to <br> and escape HTML
displayText = commentText
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/\n/g, '<br>');
// For non-markdown, escape HTML then convert line breaks to <br>
displayText = lt.escHtml(commentText).replace(/\n/g, '<br>');
}
// Add new comment to the list
@@ -538,11 +532,12 @@ function updateTicketStatus() {
return;
}
cleanup(true);
// Post comment first, then change status
// Post comment first (persists it), then change status with the same
// comment included so the server's requires_comment check passes.
const ticketId = getTicketIdFromUrl();
lt.api.post('/api/add_comment.php', { ticket_id: ticketId, comment_text: comment })
.then(() => performStatusChange(statusSelect, selectedOption, newStatus))
.catch(() => performStatusChange(statusSelect, selectedOption, newStatus));
.then(() => performStatusChange(statusSelect, selectedOption, newStatus, comment))
.catch(() => performStatusChange(statusSelect, selectedOption, newStatus, comment));
});
// Focus textarea on open
setTimeout(() => { const ta = document.getElementById(`${modalId}_comment`); if (ta) ta.focus(); }, 100);
@@ -552,8 +547,11 @@ function updateTicketStatus() {
performStatusChange(statusSelect, selectedOption, newStatus);
}
// Extract status change logic into reusable function
function performStatusChange(statusSelect, selectedOption, newStatus) {
// Extract status change logic into reusable function.
// `comment` (optional) is included in the update_ticket payload so requires_comment
// transitions pass server validation. lt.ticketStatus.submit handles the
// comment-aware retry if a comment is required but was not pre-collected.
function performStatusChange(statusSelect, selectedOption, newStatus, comment) {
const ticketId = getTicketIdFromUrl();
if (!ticketId) {
@@ -561,10 +559,10 @@ function performStatusChange(statusSelect, selectedOption, newStatus) {
return;
}
// Update status via API
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus })
// Update status via the shared comment-aware helper
lt.ticketStatus.submit(ticketId, newStatus, { comment: comment })
.then(data => {
if (data.success) {
if (data && data.success) {
// Update the dropdown to show new status as current (preserve TDS v1.2 classes)
const newClass = 'lt-status-' + newStatus.toLowerCase().replace(/ /g, '-');
statusSelect.className = 'lt-select lt-select-sm lt-status-select ' + newClass;
@@ -582,12 +580,14 @@ function performStatusChange(statusSelect, selectedOption, newStatus) {
window.location.reload();
}, 500);
} else {
lt.toast.error('Error updating status: ' + (data.error || 'Unknown error'));
lt.toast.error('Error updating status: ' + ((data && data.error) || 'Unknown error'));
// Reset to current status
statusSelect.selectedIndex = 0;
}
})
.catch(error => {
// User cancelled the required-comment modal — silently revert the dropdown
if (error && error.cancelled) { statusSelect.selectedIndex = 0; return; }
lt.toast.error('Error updating status: ' + error.message);
// Reset to current status
statusSelect.selectedIndex = 0;
@@ -938,6 +938,8 @@ function handleFileUpload(files) {
if (xhr.status === 200 || xhr.status === 201) {
try {
const response = JSON.parse(xhr.responseText);
// Keep the CSRF token in sync if the server rotated it
if (response.csrf_token) window.CSRF_TOKEN = response.csrf_token;
if (response.success) {
if (uploadedCount === totalFiles) {
lt.toast.success(`${totalFiles} file(s) uploaded successfully`, 3000);
@@ -968,6 +970,9 @@ function handleFileUpload(files) {
});
xhr.open('POST', '/api/upload_attachment.php');
// Send CSRF via header to match the rest of the app (endpoint accepts both
// the X-CSRF-Token header and the csrf_token form field).
if (window.CSRF_TOKEN) xhr.setRequestHeader('X-CSRF-Token', window.CSRF_TOKEN);
xhr.send(formData);
});
}
@@ -1142,12 +1147,17 @@ function handleMentionInput(e) {
const text = textarea.value;
const cursorPos = textarea.selectionStart;
// Find @ symbol before cursor
// Find @ symbol before cursor. Only trigger when the @ is at a word boundary
// (start of input or preceded by whitespace) so it does not fire inside email
// addresses like foo@bar.
let atPos = -1;
for (let i = cursorPos - 1; i >= 0; i--) {
const char = text[i];
if (char === '@') {
atPos = i;
const prev = i > 0 ? text[i - 1] : '';
if (i === 0 || /\s/.test(prev)) {
atPos = i;
}
break;
}
if (char === ' ' || char === '\n') {
@@ -1277,20 +1287,27 @@ function selectMention(username) {
}
/**
* Highlight mentions in comment text
* Highlight mentions in comment text.
* Skips content inside existing anchor tags so URLs/emails that contain '@'
* (e.g. auto-linked links or mailto:) are not corrupted or nested.
*/
function highlightMentions(text) {
return text.replace(/@([a-zA-Z0-9_-]+)/g, '<span class="mention">$1</span>');
return text.replace(/<a\b[^>]*>[\s\S]*?<\/a>|@[a-zA-Z0-9_-]+/gi, function (m) {
if (m.charAt(0) === '<') return m; // leave anchor tags untouched
return '<span class="mention">' + m.slice(1) + '</span>';
});
}
// Initialize mention autocomplete when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
initMentionAutocomplete();
// Highlight @mentions in plain-text comments (markdown.js handles [data-markdown] elements)
// Highlight @mentions in plain-text comments (markdown.js handles [data-markdown] elements).
// Idempotency guard: only process each element once so re-runs don't nest spans.
document.querySelectorAll('.comment-text').forEach(el => {
if (!el.hasAttribute('data-markdown')) {
if (!el.hasAttribute('data-markdown') && !el.dataset.mentionsProcessed) {
el.innerHTML = highlightMentions(el.innerHTML);
el.dataset.mentionsProcessed = '1';
}
});
+13
View File
@@ -6,6 +6,9 @@ if (!file_exists($envFile)) {
die('Configuration error: .env file not found. Copy .env.example to .env and configure your database settings.');
}
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
if (!is_array($envVars)) {
die('Configuration error: .env file could not be parsed. Check for unquoted special characters (e.g. #, ;, =, or quotes) in values and wrap affected values in double quotes.');
}
// Strip quotes from values if present (parse_ini_file may include them)
if ($envVars) {
@@ -60,6 +63,16 @@ $GLOBALS['config'] = [
'DB_PASS' => $envVars['DB_PASS'] ?? '',
'DB_NAME' => $envVars['DB_NAME'] ?? 'tinkertickets',
// Trusted reverse proxies. Authelia forward-auth (Remote-* headers) is only
// honored when REMOTE_ADDR is in this allowlist, so the spoofable identity
// headers can't be set by anything that reaches PHP directly. Comma-separated
// IPs in .env (e.g. TRUSTED_PROXIES=10.10.10.27). Empty = enforcement OFF
// (backward compatible — relies solely on network topology).
'TRUSTED_PROXIES' => array_values(array_filter(array_map(
'trim',
explode(',', (string)($envVars['TRUSTED_PROXIES'] ?? ''))
), fn($ip) => $ip !== '')),
// URL settings
'BASE_URL' => '', // Empty since we're serving from document root
'ASSETS_URL' => '/assets', // Assets URL
+28
View File
@@ -0,0 +1,28 @@
<?php
/**
* Runtime requirements single source of truth.
*
* Consumed by:
* - scripts/check_requirements.php (CI: fails the build if unmet)
* - api/health.php (production: surfaces drift to monitoring)
*
* This exists because a PHP upgrade once silently dropped the ldap extension,
* which broke avatars with no visible error. Keep this list in sync with the
* extensions the code actually relies on.
*/
return [
// Minimum supported PHP version (production runs 8.4).
'min_php_version' => '8.2',
// Extensions the application requires to function.
'required_extensions' => [
'ldap', // api/user_avatar.php — lldap avatar lookups
'mysqli', // helpers/Database.php — all data access
'curl', // helpers/NotificationHelper.php, SynapseHelper.php — Matrix
'mbstring', // multibyte string handling
'fileinfo', // api/upload_attachment.php — MIME validation
'json', // request/response encoding (bundled, but assert anyway)
],
];
+24 -3
View File
@@ -93,19 +93,27 @@ class TicketController
$visibilityGroups = implode(',', array_map('trim', $_POST['visibility_groups']));
}
// Honor the posted status, validated against the app's canonical list
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
$status = $_POST['status'] ?? 'Open';
if (!in_array($status, $validStatuses, true)) {
$status = 'Open';
}
$ticketData = [
'title' => $_POST['title'] ?? '',
'title' => trim($_POST['title'] ?? ''),
'description' => $_POST['description'] ?? '',
'priority' => $_POST['priority'] ?? '4',
'category' => $_POST['category'] ?? 'General',
'type' => $_POST['type'] ?? 'Issue',
'status' => $status,
'visibility' => $_POST['visibility'] ?? 'public',
'visibility_groups' => $visibilityGroups,
'assigned_to' => !empty($_POST['assigned_to']) ? $_POST['assigned_to'] : null
];
// Validate input
if (empty($ticketData['title'])) {
// Validate input (server-side; form is novalidate)
if ($ticketData['title'] === '') {
$error = "Title is required";
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
@@ -114,6 +122,15 @@ class TicketController
return;
}
if (trim($ticketData['description']) === '') {
$error = "Description is required";
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn; // Make $conn available to view
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
// Create ticket with user tracking
$result = $this->ticketModel->createTicket($ticketData, $userId);
@@ -123,6 +140,10 @@ class TicketController
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
}
// Ticket counts changed — invalidate the cached dashboard stats
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($this->conn))->invalidateCache();
// Auto-link as duplicate if requested from create form
$linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
+97 -28
View File
@@ -45,9 +45,11 @@ $conn = new mysqli(
);
if ($conn->connect_error) {
error_log('create_ticket_api: DB connection failed: ' . $conn->connect_error);
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Database connection failed: ' . $conn->connect_error
'error' => 'Internal server error'
]);
exit;
}
@@ -58,6 +60,7 @@ require_once __DIR__ . '/config/config.php';
// Authenticate via API key
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
require_once __DIR__ . '/models/AuditLogModel.php';
require_once __DIR__ . '/models/StatsModel.php';
require_once __DIR__ . '/helpers/UrlHelper.php';
$apiKeyAuth = new ApiKeyAuth($conn);
@@ -71,18 +74,6 @@ try {
$userId = $systemUser['user_id'];
// Create tickets table with hash column if not exists
$createTableSQL = "CREATE TABLE IF NOT EXISTS tickets (
id INT AUTO_INCREMENT PRIMARY KEY,
ticket_id VARCHAR(9) NOT NULL,
title VARCHAR(255) NOT NULL,
hash VARCHAR(64) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_hash (hash)
)";
$conn->query($createTableSQL);
// Parse input regardless of content-type header
$rawInput = file_get_contents('php://input');
$data = json_decode($rawInput, true);
@@ -193,12 +184,24 @@ function generateTicketHash($data)
'source_type' => $sourceType,
'issue_category' => $issueCategory,
'issue_subtype' => $issueSubtype,
'environment_tags' => array_values(array_filter(
explode('][', $title),
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'])
)),
'environment_tags' => (function () use ($title) {
// Extract each [bracketed] tag, then keep the known environment ones.
// (explode('][') leaves brackets stuck to the first/last tag, so e.g.
// "[production] ..." never matched and the env tag was dropped from the
// dedup hash — letting prod and staging issues collide onto one ticket.)
preg_match_all('/\[([^\]]+)\]/', $title, $m);
return array_values(array_filter(
$m[1],
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'], true)
));
})(),
];
// Manual tickets should be unique by title (so different software installs don't collide)
if ($sourceType === 'manual') {
$stableComponents['title'] = $title;
}
// Include hostname for node-specific issues
if (!$isClusterWide) {
$stableComponents['hostname'] = $hostname;
@@ -224,6 +227,22 @@ $priority = $data['priority'] ?? '4';
$category = (string)($data['category'] ?? 'General');
$type = (string)($data['type'] ?? 'Issue');
// Validate externally-supplied status and priority. (category/type are free-form
// in this schema.) A non-numeric priority would otherwise cast to 0 and escalate
// the ticket below P1 on the dedup/update path.
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
if (!in_array($status, $validStatuses, true)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid status']);
exit;
}
if (!is_numeric($priority) || (int)$priority < 1 || (int)$priority > 5) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid priority (must be 1-5)']);
exit;
}
$priority = (int)$priority;
$ticketHash = generateTicketHash($data);
$auditLog = new AuditLogModel($conn);
@@ -282,9 +301,18 @@ if ($existing) {
$updStmt->close();
// Only post a comment on priority escalation — title and description updates
// are silent (title changes like rising counters would spam a comment every run)
// are silent (title changes like rising counters would spam a comment every run).
// Keep it short: the full sensor data is refreshed in the ticket description,
// so the comment just records the bump + a brief reason (no ASCII dump).
if (isset($changes['priority'])) {
$commentText = "**hwmonDaemon escalated this ticket from P{$changes['priority']['from']} to P{$changes['priority']['to']}.**\n\n```\n" . $description . "\n```";
$pLabels = [1 => 'P1 (Critical)', 2 => 'P2 (High)', 3 => 'P3 (Medium)', 4 => 'P4 (Low)', 5 => 'P5 (Minimal)'];
$fromP = (int)$changes['priority']['from'];
$toP = (int)$changes['priority']['to'];
$fromL = $pLabels[$fromP] ?? "P{$fromP}";
$toL = $pLabels[$toP] ?? "P{$toP}";
$commentText = "**hwmonDaemon raised priority {$fromL}{$toL}.**\n\n"
. "The latest monitoring scan reported a more severe condition for this issue, "
. "so it now needs faster attention. Current sensor data is in the ticket description above.";
$commentStmt = $conn->prepare(
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
);
@@ -310,6 +338,9 @@ if ($existing) {
'status' => $existingStatus,
], 'automated');
}
// Ticket state (priority/title/description) changed — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
}
$conn->close();
@@ -332,7 +363,8 @@ if ($existing) {
$reopenStmt->close();
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
"New report received from hwmonDaemon:\n\n```\n" . $description . "\n```";
"hwmonDaemon detected this condition again. The ticket description reflects the "
. "original report; see this comment's timestamp for when the issue recurred.";
$commentStmt = $conn->prepare(
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
);
@@ -345,6 +377,9 @@ if ($existing) {
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
]);
// Ticket reopened (Closed → Open) — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
$conn->close();
require_once __DIR__ . '/helpers/NotificationHelper.php';
@@ -365,13 +400,40 @@ if ($existing) {
exit;
}
// No existing ticket — create a new one
// Use random_int range 100000000-999999999 to avoid leading-zero IDs
try {
$ticket_id = (string)random_int(100000000, 999999999);
} catch (Exception $e) {
$ticket_id = (string)mt_rand(100000000, 999999999);
// No existing ticket — create a new one.
// Generate a collision-safe unique ticket_id with a pre-check + retry loop (same
// approach as TicketModel::createTicket) so a ticket_id clash cannot happen. That
// way a 1062 on INSERT below can only be the unique_hash (dedup) key racing, and
// is correctly reported as a duplicate rather than a dropped hardware alert.
$ticket_id = null;
$maxAttempts = 50;
$attempts = 0;
do {
try {
$candidateId = sprintf('%09d', random_int(100000000, 999999999));
} catch (Exception $e) {
$candidateId = sprintf('%09d', mt_rand(100000000, 999999999));
}
$idCheckStmt = $conn->prepare("SELECT ticket_id FROM tickets WHERE ticket_id = ? LIMIT 1");
$idCheckStmt->bind_param("s", $candidateId);
$idCheckStmt->execute();
$idExists = $idCheckStmt->get_result()->num_rows > 0;
$idCheckStmt->close();
if (!$idExists) {
$ticket_id = $candidateId;
}
$attempts++;
} while ($ticket_id === null && $attempts < $maxAttempts);
if ($ticket_id === null) {
error_log('create_ticket_api: failed to generate a unique ticket_id after ' . $maxAttempts . ' attempts');
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Internal server error']);
exit;
}
$insertStmt = $conn->prepare(
"INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
@@ -397,7 +459,9 @@ try {
// Race condition: another node inserted the same hash between our SELECT and INSERT
echo json_encode(['success' => false, 'error' => 'Duplicate ticket']);
} else {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
error_log('create_ticket_api: insert failed: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Internal server error']);
}
exit;
}
@@ -411,6 +475,9 @@ if ($inserted) {
'type' => $type,
]);
// New ticket created — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
$conn->close();
require_once __DIR__ . '/helpers/NotificationHelper.php';
@@ -428,5 +495,7 @@ if ($inserted) {
'message' => 'Ticket created successfully',
]);
} else {
echo json_encode(['success' => false, 'error' => $conn->error]);
error_log('create_ticket_api: ticket insert reported failure: ' . $conn->error);
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Internal server error']);
}
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env php
<?php
/**
* Audit Log Retention Cron Job
*
* Deletes audit_log rows older than AUDIT_LOG_RETENTION_DAYS (config, default 90).
* Recommended: run once daily.
*
* Example crontab entry (03:30 every day):
* 30 3 * * * /usr/bin/php /path/to/cron/cleanup_audit_log.php >> /var/log/audit_log_cleanup.log 2>&1
*/
// Prevent web access
if (php_sapi_name() !== 'cli') {
http_response_code(403);
exit('CLI access only');
}
// Change to project root directory
chdir(dirname(__DIR__));
// Include required files
require_once 'config/config.php';
require_once 'helpers/Database.php';
require_once 'models/AuditLogModel.php';
// Log function
function logMessage($message)
{
echo '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n";
}
$retentionDays = (int)($GLOBALS['config']['AUDIT_LOG_RETENTION_DAYS'] ?? 90);
logMessage("Starting audit log cleanup (retention: {$retentionDays} days)");
try {
$conn = Database::getConnection();
$auditLog = new AuditLogModel($conn);
$deleted = $auditLog->deleteOldLogs($retentionDays);
logMessage("Removed {$deleted} audit log row(s) older than {$retentionDays} days");
Database::close();
} catch (Exception $e) {
logMessage('FATAL ERROR: ' . $e->getMessage());
exit(1);
}
+3 -5
View File
@@ -6,12 +6,10 @@
*
* Cleans up expired rate limit files from the temp directory.
* Should be run via cron every 5-10 minutes:
* */
5 * * * * / usr / bin / php / path / to / cron / cleanup_ratelimit . php
* 5 * * * * /usr/bin/php /path/to/cron/cleanup_ratelimit.php
*
* This script can also be run manually for immediate cleanup .
* /
* This script can also be run manually for immediate cleanup.
*/
// Prevent web access
if (php_sapi_name() !== 'cli') {
+34 -23
View File
@@ -5,22 +5,23 @@
* Recurring Tickets Cron Job
*
* Run this script via cron to automatically create tickets from recurring schedules.
* Recommended: Run every 5-15 minutes
* Recommended: run every 5-15 minutes.
*
* Example crontab entry:
* */
10 * * * * / usr / bin / php / path / to / cron / create_recurring_tickets . php >> / var / log / recurring_tickets . log 2 > & 1
* /
* Example crontab entry (minute 10 of every hour):
* 10 * * * * /usr/bin/php /path/to/cron/create_recurring_tickets.php >> /var/log/recurring_tickets.log 2>&1
*/
// Change to project root directory
chdir(dirname(__DIR__));
// Include required files
require_once 'config/config.php';
require_once 'helpers/Database.php';
require_once 'helpers/NotificationHelper.php';
require_once 'models/RecurringTicketModel.php';
require_once 'models/TicketModel.php';
require_once 'models/AuditLogModel.php';
require_once 'models/StatsModel.php';
// Log function
function logMessage($message)
@@ -31,17 +32,9 @@ function logMessage($message)
logMessage("Starting recurring tickets cron job");
try {
// Create database connection
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
throw new Exception("Database connection failed: " . $conn->connect_error);
}
// Create database connection (Database::getConnection sets utf8mb4 so
// non-ASCII titles/descriptions aren't corrupted on insert).
$conn = Database::getConnection();
// Initialize models
$recurringModel = new RecurringTicketModel($conn);
@@ -59,6 +52,14 @@ try {
logMessage("Processing recurring ticket ID: " . $recurring['recurring_id']);
try {
// Claim the schedule FIRST (atomic advance of next_run_at). If another
// cron run already claimed it, or it's no longer due, skip it — this
// prevents duplicate-ticket floods if a later step throws.
if (!$recurringModel->claimForRun($recurring['recurring_id'])) {
logMessage("Skipped (already claimed or not due): " . $recurring['recurring_id']);
continue;
}
// Prepare ticket data
$ticketData = [
'title' => processTemplate($recurring['title_template']),
@@ -76,9 +77,12 @@ try {
$ticketId = $result['ticket_id'];
logMessage("Created ticket: " . $ticketId);
// Assign to user if specified
if ($recurring['assigned_to']) {
$ticketModel->assignTicket($ticketId, $recurring['assigned_to'], $recurring['created_by']);
// Assign to user if specified. assignTicket() requires a non-null
// "assigned_by"; fall back to the assignee when created_by is null
// (recurring schedules may have no creator).
if (!empty($recurring['assigned_to'])) {
$assignedBy = (int)($recurring['created_by'] ?? $recurring['assigned_to']);
$ticketModel->assignTicket($ticketId, (int)$recurring['assigned_to'], $assignedBy);
}
// Log to audit
@@ -90,8 +94,9 @@ try {
['source' => 'recurring', 'recurring_id' => $recurring['recurring_id']]
);
// Update the recurring ticket's next run time
$recurringModel->updateAfterRun($recurring['recurring_id']);
// Fire the same Matrix "ticket created" notification the manual and
// external-API create paths send, so recurring tickets aren't silent.
NotificationHelper::sendTicketNotification($ticketId, $ticketData, 'automated');
$created++;
} else {
@@ -104,9 +109,15 @@ try {
}
}
// Ticket counts changed — invalidate the cached dashboard stats once for the
// whole run (mirrors the manual/API create paths, which invalidate per create).
if ($created > 0) {
(new StatsModel($conn))->invalidateCache();
}
logMessage("Completed: Created $created tickets, $errors errors");
$conn->close();
Database::close();
} catch (Exception $e) {
logMessage("FATAL ERROR: " . $e->getMessage());
exit(1);
-107
View File
@@ -1,107 +0,0 @@
<?php
/**
* API Key Generator for hwmonDaemon
* Run this script once after migrations to generate the API key
*
* Usage: php generate_api_key.php
*/
// Prevent web access
if (php_sapi_name() !== 'cli') {
http_response_code(403);
exit('CLI access only');
}
require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/models/ApiKeyModel.php';
require_once __DIR__ . '/models/UserModel.php';
echo "==============================================\n";
echo " Tinker Tickets - API Key Generator\n";
echo "==============================================\n\n";
// Create database connection
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
die("❌ Database connection failed: " . $conn->connect_error . "\n");
}
echo "✅ Connected to database\n\n";
// Initialize models
$userModel = new UserModel($conn);
$apiKeyModel = new ApiKeyModel($conn);
// Get system user (should exist from migration)
echo "Checking for system user...\n";
$systemUser = $userModel->getSystemUser();
if (!$systemUser) {
die("❌ Error: System user not found. Please run migrations first.\n");
}
echo "✅ System user found: ID " . $systemUser['user_id'] . " (" . $systemUser['username'] . ")\n\n";
// Check if API key already exists
$existingKeys = $apiKeyModel->getKeysByUser($systemUser['user_id']);
if (!empty($existingKeys)) {
echo "⚠️ Warning: API keys already exist for system user:\n\n";
foreach ($existingKeys as $key) {
echo " - " . $key['key_name'] . " (Prefix: " . $key['key_prefix'] . ")\n";
echo " Created: " . $key['created_at'] . "\n";
echo " Active: " . ($key['is_active'] ? 'Yes' : 'No') . "\n\n";
}
echo "Do you want to generate a new API key? (yes/no): ";
$handle = fopen("php://stdin", "r");
$response = trim(fgets($handle));
fclose($handle);
if (strtolower($response) !== 'yes') {
echo "\nAborted.\n";
exit(0);
}
echo "\n";
}
// Generate API key
echo "Generating API key for hwmonDaemon...\n";
$result = $apiKeyModel->createKey(
'hwmonDaemon',
$systemUser['user_id'],
null // No expiration
);
if ($result['success']) {
echo "\n";
echo "==============================================\n";
echo " ✅ API Key Generated Successfully!\n";
echo "==============================================\n\n";
echo "API Key: " . $result['api_key'] . "\n";
echo "Key Prefix: " . $result['key_prefix'] . "\n";
echo "Key ID: " . $result['key_id'] . "\n";
echo "Expires: Never\n\n";
echo "⚠️ IMPORTANT: Save this API key now!\n";
echo " It cannot be retrieved later.\n\n";
echo "==============================================\n";
echo " Add to hwmonDaemon .env file:\n";
echo "==============================================\n\n";
echo "TICKET_API_KEY=" . $result['api_key'] . "\n\n";
echo "Then restart hwmonDaemon:\n";
echo " sudo systemctl restart hwmonDaemon\n\n";
} else {
echo "❌ Error generating API key: " . $result['error'] . "\n";
exit(1);
}
$conn->close();
echo "Done! Delete this script after use:\n";
echo " rm " . __FILE__ . "\n\n";
+27 -8
View File
@@ -21,7 +21,13 @@ class CacheHelper
if (self::$cacheDir === null) {
self::$cacheDir = sys_get_temp_dir() . '/tinker_tickets_cache';
if (!is_dir(self::$cacheDir)) {
mkdir(self::$cacheDir, 0755, true);
// 0700: only the app user may read cached data or create files.
// mkdir mode is masked by umask, so chmod to enforce it.
mkdir(self::$cacheDir, 0700, true);
@chmod(self::$cacheDir, 0700);
} elseif (!function_exists('posix_geteuid') || fileowner(self::$cacheDir) === posix_geteuid()) {
// Existing dir we own: harden a previously world-readable dir.
@chmod(self::$cacheDir, 0700);
}
}
return self::$cacheDir;
@@ -106,7 +112,13 @@ class CacheHelper
// Store in file cache
$filePath = self::getCacheDir() . '/' . $key . '.json';
return @file_put_contents($filePath, json_encode($cached), LOCK_EX) !== false;
$written = @file_put_contents($filePath, json_encode($cached), LOCK_EX) !== false;
if ($written) {
// 0600: cache may feed security-relevant reads; keep it non-readable
// to other local users and non-poisonable by pre-created files.
@chmod($filePath, 0600);
}
return $written;
}
/**
@@ -125,16 +137,23 @@ class CacheHelper
return !file_exists($filePath) || @unlink($filePath);
}
// Delete all files with this prefix
$pattern = self::getCacheDir() . '/' . preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix) . '*.json';
$files = glob($pattern);
// Delete all entries for this prefix. A key is either the bare prefix or
// prefix + '_' + md5(identifier) (32 hex chars, see makeKey). Match exactly
// that so a prefix can't clobber a different prefix that merely shares a
// leading substring — e.g. delete('workflow') must not wipe 'workflow_rules'.
$safePrefix = preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix);
$keyRegex = '/^' . preg_quote($safePrefix, '/') . '(_[0-9a-f]{32})?$/';
$files = glob(self::getCacheDir() . '/' . $safePrefix . '*.json') ?: [];
foreach ($files as $file) {
@unlink($file);
if (preg_match($keyRegex, basename($file, '.json'))) {
@unlink($file);
}
}
// Clear memory cache entries with this prefix
// Clear matching memory cache entries
foreach (array_keys(self::$memoryCache) as $key) {
if (strpos($key, $prefix) === 0) {
if (preg_match($keyRegex, $key)) {
unset(self::$memoryCache[$key]);
}
}
+29 -5
View File
@@ -22,11 +22,9 @@ class Database
self::$connection = self::createConnection();
}
// Check if connection is still alive
if (!self::$connection->ping()) {
self::$connection = self::createConnection();
}
// Note: no ping()/reconnect check — mysqli auto-reconnect was removed in
// PHP 8.2 and mysqli::ping() is deprecated in 8.4. The connection is
// request-scoped and short-lived, so a liveness check is unnecessary.
return self::$connection;
}
@@ -57,6 +55,32 @@ class Database
// Set charset to utf8mb4 for proper Unicode support
$conn->set_charset('utf8mb4');
// Pin the MySQL session time zone to the app's configured zone so that
// NOW()/CURRENT_TIMESTAMP and PHP agree on wall-clock time regardless of
// the DB server's SYSTEM tz. Prefer the named zone (requires the
// mysql.time_zone_* tables); if that isn't available, fall back to the
// fixed numeric offset PHP computes for the same zone. Best-effort: a
// failure here must never fatal the connection.
$tz = $GLOBALS['config']['TIMEZONE'] ?? 'UTC';
try {
$escaped = $conn->real_escape_string($tz);
try {
// mysqli throws (does not return false) on failure under the
// default PHP 8.1+ report mode, so catch it rather than testing
// the return value.
$conn->query("SET time_zone = '{$escaped}'");
} catch (\Throwable $inner) {
// Named zone unavailable (mysql.time_zone_* not populated) — fall
// back to a fixed numeric offset so PHP and MySQL still agree on
// wall-clock time regardless of the DB server's SYSTEM tz.
$offset = (new DateTime('now', new DateTimeZone($tz)))->format('P');
$escapedOffset = $conn->real_escape_string($offset);
$conn->query("SET time_zone = '{$escapedOffset}'");
}
} catch (\Throwable $e) {
error_log('Database: failed to set session time_zone: ' . $e->getMessage());
}
return $conn;
}
+55 -18
View File
@@ -96,21 +96,31 @@ class NotificationHelper
* @param string $commentText Plain text (first 200 chars will be sent)
* @param string|null $authorDisplay Display name of commenter
* @param bool $isInternal True if the comment is internal-only
* @param string $visibility Ticket visibility: 'public', 'internal', or
* 'confidential'. For non-public tickets the
* comment text preview is redacted so it is
* never leaked to the shared notify list.
*/
public static function sendCommentNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay = null, bool $isInternal = false): void
public static function sendCommentNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay = null, bool $isInternal = false, string $visibility = 'public'): void
{
// Skip if this is an internal-only comment — only the assignee/admin need to know
$notifyUsers = self::notifyUsers();
if (empty($notifyUsers)) {
return;
}
// The shared notify list may include users without access to non-public
// tickets, so never post the comment body for internal/confidential
// tickets — only that activity occurred.
$preview = $visibility === 'public'
? mb_strimwidth($commentText, 0, 200, '…')
: null;
self::fire([
'event' => 'comment_added',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'author' => $authorDisplay,
'preview' => mb_strimwidth($commentText, 0, 200, '…'),
'preview' => $preview,
'is_internal' => $isInternal,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => $notifyUsers,
@@ -155,8 +165,14 @@ class NotificationHelper
* @param string $event One of: status_changed, comment_added, assigned
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
* @param int|null $excludeUserId Don't notify the actor themselves
* @param string $visibility Ticket visibility: 'public', 'internal', or
* 'confidential'. notify_users includes the
* shared list, which may contain users without
* access to non-public tickets, so any comment
* body preview in $extraData is redacted for
* non-public tickets.
*/
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null): void
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null, string $visibility = 'public'): void
{
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
@@ -164,23 +180,44 @@ class NotificationHelper
return;
}
// Fetch watcher usernames, excluding the actor so they don't notify themselves
if ($excludeUserId !== null) {
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ? AND tw.user_id != ?";
$stmt = $conn->prepare($sql);
$stmt->bind_param("ii", $ticketId, $excludeUserId);
} else {
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ?";
$stmt = $conn->prepare($sql);
$stmt->bind_param("i", $ticketId);
// Don't leak comment/body content to the shared notify list for
// non-public tickets — keep only the fact that activity occurred.
if ($visibility !== 'public' && isset($extraData['preview'])) {
$extraData['preview'] = null;
}
$stmt->execute();
$result = $stmt->get_result();
$stmt->close();
// Fetch watcher usernames, excluding the actor so they don't notify
// themselves. Notifications are best-effort: if the watchers table is
// absent or the query fails, skip silently rather than fataling the
// request that already committed its DB change. mysqli may either throw
// (default exception mode) or return false, so handle both.
$usernames = [];
while ($row = $result->fetch_assoc()) {
$usernames[] = $row['username'];
try {
if ($excludeUserId !== null) {
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ? AND tw.user_id != ?";
$stmt = $conn->prepare($sql);
} else {
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ?";
$stmt = $conn->prepare($sql);
}
if (!$stmt) {
return;
}
if ($excludeUserId !== null) {
$stmt->bind_param("ii", $ticketId, $excludeUserId);
} else {
$stmt->bind_param("i", $ticketId);
}
$stmt->execute();
$result = $stmt->get_result();
$stmt->close();
while ($row = $result->fetch_assoc()) {
$usernames[] = $row['username'];
}
} catch (\Throwable $e) {
error_log('NotificationHelper::notifyWatchers watcher lookup failed: ' . $e->getMessage());
return;
}
if (empty($usernames)) {
+33 -12
View File
@@ -4,8 +4,9 @@
* SynapseHelper
*
* Resolves local (SSO) usernames Matrix user IDs by querying the
* Synapse Admin REST API directly. No caching every call is live
* so results never go stale.
* Synapse Admin REST API directly. Results are memoized per-request (not
* across requests, so they don't go stale between requests), and a batch
* resolve has an overall time budget to bound request latency.
*
* Required config (.env) keys:
* MATRIX_DOMAIN e.g. matrix.lotusguild.org
@@ -14,6 +15,12 @@
*/
class SynapseHelper
{
/** Per-request memo of username => Matrix ID|null, so repeat watchers are free. */
private static array $cache = [];
/** Total wall-clock budget (seconds) for a single resolveUsernames() batch. */
private const RESOLVE_BUDGET_SECONDS = 5;
/**
* Resolve a local SSO username to its Matrix user ID.
*
@@ -29,6 +36,11 @@ class SynapseHelper
*/
public static function resolveUsername(string $username): ?string
{
// Serve from the per-request cache when we've already looked this up.
if (array_key_exists($username, self::$cache)) {
return self::$cache[$username];
}
$baseUrl = $GLOBALS['config']['SYNAPSE_ADMIN_URL'] ?? null;
$token = $GLOBALS['config']['SYNAPSE_ADMIN_TOKEN'] ?? null;
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
@@ -49,6 +61,7 @@ class SynapseHelper
'Accept: application/json',
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 2); // fail fast when Synapse is unreachable
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
$body = curl_exec($ch);
@@ -56,25 +69,24 @@ class SynapseHelper
$curlError = curl_error($ch);
curl_close($ch);
$resolved = null;
if ($curlError) {
error_log("SynapseHelper: cURL error resolving '{$username}': {$curlError}");
return null;
}
if ($httpCode === 200) {
} elseif ($httpCode === 200) {
$data = json_decode($body, true);
// Confirm the response contains the name we expect
if (!empty($data['name'])) {
return $data['name']; // e.g. "@jared:matrix.lotusguild.org"
$resolved = $data['name']; // e.g. "@jared:matrix.lotusguild.org"
}
}
// 404 = user not found in Synapse; other codes = error
if ($httpCode !== 404) {
} elseif ($httpCode !== 404) {
// 404 = user not found in Synapse; other codes = error
error_log("SynapseHelper: unexpected HTTP {$httpCode} resolving '{$username}'");
}
return null;
// Memoize for the rest of this request (including negative results, so a
// missing/unreachable user isn't retried within the same request).
self::$cache[$username] = $resolved;
return $resolved;
}
/**
@@ -87,7 +99,16 @@ class SynapseHelper
public static function resolveUsernames(array $usernames): array
{
$ids = [];
$deadline = microtime(true) + self::RESOLVE_BUDGET_SECONDS;
foreach ($usernames as $username) {
// Cached lookups are free and always allowed; for uncached ones, stop
// making live calls once the batch budget is spent so a slow/unreachable
// Synapse can't stall the request for N × per-call timeout.
$cached = array_key_exists($username, self::$cache);
if (!$cached && microtime(true) >= $deadline) {
error_log('SynapseHelper: resolve budget exhausted; skipping remaining lookups');
break;
}
$id = self::resolveUsername($username);
if ($id !== null) {
$ids[] = $id;
+11 -5
View File
@@ -249,8 +249,11 @@ switch (true) {
$params = [];
$types = '';
$allowedActionTypes = ['create','update','delete','comment','assign','status_change','login','security',
'ticket_create','ticket_update','ticket_delete','attachment_delete','attachment_upload'];
// Mirrors AuditLogModel::VALID_ACTION_TYPES so every option offered by the
// audit-log filter dropdown is actually accepted here.
$allowedActionTypes = ['create','update','delete','view','security_event',
'login','logout','assign','unassign','comment','mention',
'revoke','attachment_upload','attachment_delete','bulk_update'];
if (!empty($_GET['action_type']) && in_array($_GET['action_type'], $allowedActionTypes, true)) {
$whereConditions[] = "al.action_type = ?";
$params[] = $_GET['action_type'];
@@ -335,9 +338,12 @@ switch (true) {
case $requestPath == '/admin/user-activity':
requireAdmin($currentUser);
// Validate date params (YYYY-MM-DD) like the audit-log route; fall back to defaults on garbage
$uaFrom = $_GET['date_from'] ?? '';
$uaTo = $_GET['date_to'] ?? '';
$dateRange = [
'from' => $_GET['date_from'] ?? date('Y-m-d', strtotime('-30 days')),
'to' => $_GET['date_to'] ?? date('Y-m-d')
'from' => preg_match('/^\d{4}-\d{2}-\d{2}$/', $uaFrom) ? $uaFrom : date('Y-m-d', strtotime('-30 days')),
'to' => preg_match('/^\d{4}-\d{2}-\d{2}$/', $uaTo) ? $uaTo : date('Y-m-d')
];
// Optimized query using LEFT JOINs with aggregated subqueries instead of correlated subqueries
@@ -410,7 +416,7 @@ switch (true) {
header("Location: /");
exit;
case preg_match('/^\/ticket\.php/', $requestPath) && isset($_GET['id']):
case preg_match('/^\/ticket\.php$/', $requestPath) && isset($_GET['id']):
$legacyId = (string)$_GET['id'];
if (ctype_digit($legacyId) && (int)$legacyId > 0) {
header("Location: /ticket/" . $legacyId);
+33
View File
@@ -96,6 +96,12 @@ class AuthMiddleware
}
}
// Only honor Authelia forward-auth headers from a trusted reverse proxy.
// Without this, anything that can reach PHP directly could spoof
// Remote-User / Remote-Groups and log in (as admin). No valid session
// exists at this point, so we are about to trust request headers.
$this->enforceTrustedProxy();
// Read Authelia forward auth headers
$username = $this->getHeader('HTTP_REMOTE_USER');
$displayName = $this->getHeader('HTTP_REMOTE_NAME');
@@ -136,6 +142,33 @@ class AuthMiddleware
return $user;
}
/**
* Reject forward-auth headers that did not arrive via a trusted proxy.
*
* If TRUSTED_PROXIES is configured and the connecting REMOTE_ADDR is not in
* the allowlist, the Remote-* headers cannot be trusted, so we refuse rather
* than honor a potentially spoofed identity. Empty allowlist = disabled.
*/
private function enforceTrustedProxy(): void
{
$trusted = $GLOBALS['config']['TRUSTED_PROXIES'] ?? [];
if (empty($trusted)) {
return; // Enforcement disabled (no allowlist configured)
}
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '';
if (!in_array($remoteAddr, $trusted, true)) {
$this->logSecurityEvent('untrusted_proxy', [
'reason' => 'Remote-* auth headers from non-allowlisted source',
'remote_addr' => $remoteAddr ?: 'unknown'
]);
header('HTTP/1.1 403 Forbidden');
header('Content-Type: text/plain; charset=utf-8');
echo 'Forbidden: authentication headers must arrive via a trusted proxy.';
exit;
}
}
/**
* Get header value from server variables
*
+51 -24
View File
@@ -41,19 +41,31 @@ class RateLimitMiddleware
*/
private static function getClientIp(): string
{
// Check for forwarded IP (behind proxy/load balancer)
$headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
foreach ($headers as $header) {
if (!empty($_SERVER[$header])) {
// Take the first IP in a comma-separated list
$ips = explode(',', $_SERVER[$header]);
$ip = trim($ips[0]);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return $ip;
}
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
// Forwarded headers are client-controlled, so only believe them when the
// request actually came from a trusted reverse proxy. Otherwise a client
// could rotate X-Forwarded-For each request to escape the per-IP limit.
$trusted = $GLOBALS['config']['TRUSTED_PROXIES'] ?? [];
if (empty($trusted) || !in_array($remoteAddr, $trusted, true)) {
return $remoteAddr;
}
// The trusted proxy appends the connecting client to X-Forwarded-For, so
// the RIGHTMOST entry is the IP it observed (a client-supplied prefix is
// not trustworthy). X-Real-IP is set by the proxy itself.
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
$ip = trim(end($ips));
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
if (!empty($_SERVER['HTTP_X_REAL_IP']) && filter_var($_SERVER['HTTP_X_REAL_IP'], FILTER_VALIDATE_IP)) {
return trim($_SERVER['HTTP_X_REAL_IP']);
}
return $remoteAddr;
}
/**
@@ -72,28 +84,43 @@ class RateLimitMiddleware
$ipHash = hash('sha256', $ip . '_' . $type);
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
// Load existing rate data
// Hold an exclusive lock across the whole read-modify-write so concurrent
// requests from the same IP can't both read the same count and each write
// count+1 (which would undercount and let the limit be exceeded).
$fh = @fopen($filePath, 'c+');
if ($fh === false) {
// Can't open the counter file — fail open (don't block legitimate traffic).
return true;
}
if (!flock($fh, LOCK_EX)) {
fclose($fh);
return true;
}
$content = stream_get_contents($fh);
$rateData = ['count' => 0, 'window_start' => $now];
if (file_exists($filePath)) {
$content = @file_get_contents($filePath);
if ($content !== false) {
$decoded = json_decode($content, true);
if (is_array($decoded)) {
$rateData = $decoded;
}
if ($content !== false && $content !== '') {
$decoded = json_decode($content, true);
if (is_array($decoded)) {
$rateData = $decoded;
}
}
// Check if window has expired
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
// Reset when the window has expired
if ($now - ($rateData['window_start'] ?? $now) >= self::WINDOW_SECONDS) {
$rateData = ['count' => 0, 'window_start' => $now];
}
// Increment count
$rateData['count']++;
// Save updated data
@file_put_contents($filePath, json_encode($rateData), LOCK_EX);
// Rewrite the file in place while still holding the lock
rewind($fh);
ftruncate($fh, 0);
fwrite($fh, json_encode($rateData));
fflush($fh);
flock($fh, LOCK_UN);
fclose($fh);
// Check if over limit
return $rateData['count'] <= $limit;
+326
View File
@@ -0,0 +1,326 @@
-- =====================================================================
-- 000_baseline.sql — full schema baseline for tinker_tickets
--
-- Captured from the live production database so the schema is
-- reproducible from source (a fresh install or disaster recovery).
-- Every table uses CREATE TABLE IF NOT EXISTS, so running this against
-- an existing database is a safe no-op. FK checks are disabled during
-- creation so table order does not matter.
-- =====================================================================
SET FOREIGN_KEY_CHECKS = 0;
-- ============ api_keys ============
CREATE TABLE IF NOT EXISTS `api_keys` (
`api_key_id` int(11) NOT NULL AUTO_INCREMENT,
`key_name` varchar(100) NOT NULL,
`key_hash` varchar(255) NOT NULL,
`key_prefix` varchar(20) NOT NULL,
`is_active` tinyint(1) DEFAULT 1,
`created_by` int(11) DEFAULT NULL,
`last_used` timestamp NULL DEFAULT NULL,
`expires_at` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`api_key_id`),
UNIQUE KEY `key_hash` (`key_hash`),
KEY `created_by` (`created_by`),
KEY `idx_key_hash` (`key_hash`),
KEY `idx_is_active` (`is_active`),
CONSTRAINT `api_keys_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ audit_log ============
CREATE TABLE IF NOT EXISTS `audit_log` (
`audit_id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` int(11) DEFAULT NULL,
`action_type` varchar(50) NOT NULL,
`entity_type` varchar(50) NOT NULL,
`entity_id` varchar(50) DEFAULT NULL,
`details` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`details`)),
`ip_address` varchar(45) DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`audit_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_created_at` (`created_at`),
KEY `idx_entity` (`entity_type`,`entity_id`),
KEY `idx_action_type` (`action_type`),
KEY `idx_audit_log_user_created` (`user_id`,`created_at` DESC),
KEY `idx_audit_log_action_type` (`action_type`,`created_at` DESC),
KEY `idx_audit_entity` (`entity_type`,`entity_id`),
KEY `idx_audit_user` (`user_id`,`created_at`),
CONSTRAINT `audit_log_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ bulk_operations ============
CREATE TABLE IF NOT EXISTS `bulk_operations` (
`operation_id` int(11) NOT NULL AUTO_INCREMENT,
`operation_type` varchar(50) NOT NULL,
`ticket_ids` text NOT NULL,
`performed_by` int(11) NOT NULL,
`parameters` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`parameters`)),
`status` varchar(20) DEFAULT 'pending',
`total_tickets` int(11) DEFAULT NULL,
`processed_tickets` int(11) DEFAULT 0,
`failed_tickets` int(11) DEFAULT 0,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`completed_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`operation_id`),
KEY `idx_performed_by` (`performed_by`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `bulk_operations_ibfk_1` FOREIGN KEY (`performed_by`) REFERENCES `users` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ custom_field_definitions ============
CREATE TABLE IF NOT EXISTS `custom_field_definitions` (
`field_id` int(11) NOT NULL AUTO_INCREMENT,
`field_name` varchar(100) NOT NULL,
`field_label` varchar(255) NOT NULL,
`field_type` enum('text','textarea','select','checkbox','date','number') NOT NULL,
`field_options` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Options for select fields: {"options": ["Option 1", "Option 2"]}' CHECK (json_valid(`field_options`)),
`category` varchar(50) DEFAULT NULL COMMENT 'NULL = applies to all categories',
`is_required` tinyint(1) DEFAULT 0,
`display_order` int(11) DEFAULT 0,
`is_active` tinyint(1) DEFAULT 1,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`field_id`),
KEY `idx_custom_fields_category` (`category`,`is_active`),
KEY `idx_custom_fields_order` (`display_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ custom_field_values ============
CREATE TABLE IF NOT EXISTS `custom_field_values` (
`value_id` int(11) NOT NULL AUTO_INCREMENT,
`ticket_id` varchar(9) NOT NULL,
`field_id` int(11) NOT NULL,
`field_value` text DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`value_id`),
UNIQUE KEY `unique_ticket_field` (`ticket_id`,`field_id`),
KEY `field_id` (`field_id`),
KEY `idx_custom_values_ticket` (`ticket_id`),
CONSTRAINT `custom_field_values_ibfk_1` FOREIGN KEY (`field_id`) REFERENCES `custom_field_definitions` (`field_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ migrations ============
CREATE TABLE IF NOT EXISTS `migrations` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`filename` varchar(255) NOT NULL,
`applied_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `filename` (`filename`),
KEY `idx_filename` (`filename`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ recurring_tickets ============
CREATE TABLE IF NOT EXISTS `recurring_tickets` (
`recurring_id` int(11) NOT NULL AUTO_INCREMENT,
`title_template` varchar(255) NOT NULL,
`description_template` text DEFAULT NULL,
`category` varchar(50) DEFAULT 'General',
`type` varchar(50) DEFAULT 'Task',
`priority` int(11) DEFAULT 4,
`assigned_to` int(11) DEFAULT NULL,
`schedule_type` enum('daily','weekly','monthly') NOT NULL,
`schedule_day` int(11) DEFAULT NULL COMMENT 'Day of week (1-7) for weekly, day of month (1-31) for monthly',
`schedule_time` time DEFAULT '09:00:00',
`next_run_at` timestamp NOT NULL,
`last_run_at` timestamp NULL DEFAULT NULL,
`is_active` tinyint(1) DEFAULT 1,
`created_by` int(11) DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`recurring_id`),
KEY `assigned_to` (`assigned_to`),
KEY `created_by` (`created_by`),
KEY `idx_recurring_next_run` (`next_run_at`,`is_active`),
KEY `idx_recurring_active` (`is_active`),
CONSTRAINT `recurring_tickets_ibfk_1` FOREIGN KEY (`assigned_to`) REFERENCES `users` (`user_id`) ON DELETE SET NULL,
CONSTRAINT `recurring_tickets_ibfk_2` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ saved_filters ============
CREATE TABLE IF NOT EXISTS `saved_filters` (
`filter_id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`filter_name` varchar(100) NOT NULL,
`filter_criteria` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL CHECK (json_valid(`filter_criteria`)),
`is_default` tinyint(1) DEFAULT 0,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`filter_id`),
UNIQUE KEY `unique_user_filter_name` (`user_id`,`filter_name`),
KEY `idx_user_filters` (`user_id`,`is_default`),
CONSTRAINT `saved_filters_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============ status_transitions ============
CREATE TABLE IF NOT EXISTS `status_transitions` (
`transition_id` int(11) NOT NULL AUTO_INCREMENT,
`from_status` varchar(50) NOT NULL,
`to_status` varchar(50) NOT NULL,
`requires_comment` tinyint(1) DEFAULT 0,
`requires_admin` tinyint(1) DEFAULT 0,
`is_active` tinyint(1) DEFAULT 1,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`transition_id`),
UNIQUE KEY `unique_transition` (`from_status`,`to_status`),
KEY `idx_from_status` (`from_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ ticket_attachments ============
CREATE TABLE IF NOT EXISTS `ticket_attachments` (
`attachment_id` int(11) NOT NULL AUTO_INCREMENT,
`ticket_id` varchar(9) NOT NULL,
`filename` varchar(255) NOT NULL,
`original_filename` varchar(255) NOT NULL,
`file_size` int(11) NOT NULL,
`mime_type` varchar(100) NOT NULL,
`uploaded_by` int(11) DEFAULT NULL,
`uploaded_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`attachment_id`),
KEY `idx_attachments_ticket` (`ticket_id`),
KEY `idx_attachments_uploaded_by` (`uploaded_by`),
CONSTRAINT `ticket_attachments_ibfk_1` FOREIGN KEY (`uploaded_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============ ticket_comments ============
CREATE TABLE IF NOT EXISTS `ticket_comments` (
`comment_id` int(11) NOT NULL AUTO_INCREMENT,
`parent_comment_id` int(11) DEFAULT NULL,
`thread_depth` tinyint(3) unsigned NOT NULL DEFAULT 0,
`ticket_id` varchar(10) DEFAULT NULL,
`user_name` varchar(50) DEFAULT NULL,
`comment_text` text DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`markdown_enabled` tinyint(1) DEFAULT 0,
`user_id` int(11) DEFAULT NULL,
PRIMARY KEY (`comment_id`),
KEY `fk_comments_user_id` (`user_id`),
KEY `idx_comments_ticket_created` (`ticket_id`,`created_at` DESC),
KEY `idx_parent_comment` (`parent_comment_id`),
CONSTRAINT `fk_comments_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE SET NULL,
CONSTRAINT `fk_parent_comment` FOREIGN KEY (`parent_comment_id`) REFERENCES `ticket_comments` (`comment_id`) ON DELETE CASCADE,
CONSTRAINT `ticket_comments_ibfk_1` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`ticket_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ ticket_dependencies ============
CREATE TABLE IF NOT EXISTS `ticket_dependencies` (
`dependency_id` int(11) NOT NULL AUTO_INCREMENT,
`ticket_id` varchar(9) NOT NULL,
`depends_on_id` varchar(9) NOT NULL,
`dependency_type` enum('blocks','blocked_by','relates_to','duplicates') DEFAULT 'blocks',
`created_by` int(11) DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`dependency_id`),
UNIQUE KEY `unique_dependency` (`ticket_id`,`depends_on_id`,`dependency_type`),
KEY `idx_ticket_id` (`ticket_id`),
KEY `idx_depends_on_id` (`depends_on_id`),
KEY `created_by` (`created_by`),
CONSTRAINT `ticket_dependencies_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ ticket_templates ============
CREATE TABLE IF NOT EXISTS `ticket_templates` (
`template_id` int(11) NOT NULL AUTO_INCREMENT,
`template_name` varchar(100) NOT NULL,
`title_template` varchar(255) NOT NULL,
`description_template` text NOT NULL,
`category` varchar(50) DEFAULT NULL,
`type` varchar(50) DEFAULT NULL,
`default_priority` int(11) DEFAULT 4,
`created_by` int(11) DEFAULT NULL,
`is_active` tinyint(1) DEFAULT 1,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`template_id`),
KEY `created_by` (`created_by`),
KEY `idx_template_name` (`template_name`),
CONSTRAINT `ticket_templates_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ ticket_watchers ============
CREATE TABLE IF NOT EXISTS `ticket_watchers` (
`ticket_id` int(11) NOT NULL,
`user_id` int(11) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`ticket_id`,`user_id`),
KEY `idx_watcher_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ tickets ============
CREATE TABLE IF NOT EXISTS `tickets` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`ticket_id` varchar(9) NOT NULL,
`title` varchar(255) NOT NULL,
`category` varchar(100) DEFAULT NULL,
`type` varchar(100) DEFAULT NULL,
`visibility` enum('public','internal','confidential') DEFAULT 'public',
`visibility_groups` varchar(500) DEFAULT NULL,
`status` varchar(20) NOT NULL DEFAULT 'Open',
`description` text DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`closed_at` timestamp NULL DEFAULT NULL,
`priority` int(11) NOT NULL DEFAULT 1 CHECK (`priority` between 1 and 6),
`hash` varchar(64) DEFAULT NULL,
`created_by` int(11) DEFAULT NULL,
`updated_by` int(11) DEFAULT NULL,
`assigned_to` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ticket_id` (`ticket_id`),
UNIQUE KEY `unique_hash` (`hash`),
KEY `fk_tickets_updated_by` (`updated_by`),
KEY `idx_status` (`status`),
KEY `idx_priority` (`priority`),
KEY `idx_tickets_created_at` (`created_at`),
KEY `idx_assigned_to` (`assigned_to`),
KEY `idx_tickets_status` (`status`),
KEY `idx_tickets_status_priority_created` (`status`,`priority`,`created_at` DESC),
KEY `idx_tickets_visibility` (`visibility`),
KEY `idx_tickets_category` (`category`),
KEY `idx_tickets_type` (`type`),
KEY `idx_tickets_priority` (`priority`),
KEY `idx_tickets_updated_at` (`updated_at`),
KEY `idx_tickets_created_by` (`created_by`),
KEY `idx_tickets_assigned_to` (`assigned_to`),
KEY `idx_tickets_status_created` (`status`,`created_at`),
KEY `idx_tickets_assigned_status` (`assigned_to`,`status`),
KEY `idx_tickets_visibility_status` (`visibility`,`status`),
KEY `idx_tickets_closed_at` (`closed_at`),
FULLTEXT KEY `ft_title_description` (`title`,`description`),
CONSTRAINT `fk_tickets_assigned_to` FOREIGN KEY (`assigned_to`) REFERENCES `users` (`user_id`) ON DELETE SET NULL,
CONSTRAINT `fk_tickets_created_by` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL,
CONSTRAINT `fk_tickets_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ user_preferences ============
CREATE TABLE IF NOT EXISTS `user_preferences` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`preference_key` varchar(100) NOT NULL,
`preference_value` text DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `unique_user_pref` (`user_id`,`preference_key`),
KEY `idx_user_preferences_user_key` (`user_id`,`preference_key`),
CONSTRAINT `user_preferences_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ users ============
CREATE TABLE IF NOT EXISTS `users` (
`user_id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(100) NOT NULL,
`display_name` varchar(255) DEFAULT NULL,
`email` varchar(255) DEFAULT NULL,
`groups` text DEFAULT NULL,
`is_admin` tinyint(1) DEFAULT 0,
`last_login` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`user_id`),
UNIQUE KEY `username` (`username`),
KEY `idx_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
SET FOREIGN_KEY_CHECKS = 1;
+49 -23
View File
@@ -10,19 +10,24 @@ class AuditLogModel
/** @var int Maximum allowed limit for pagination */
private const MAX_LIMIT = 1000;
/** @var int Maximum rows for a CSV/forensic export (higher than the UI cap) */
private const EXPORT_LIMIT = 100000;
/** @var int Default limit for pagination */
private const DEFAULT_LIMIT = 100;
/** @var array Allowed action types for filtering */
private const VALID_ACTION_TYPES = [
'create', 'update', 'delete', 'view', 'security_event',
'login', 'logout', 'assign', 'comment', 'bulk_update'
'login', 'logout', 'assign', 'unassign', 'comment', 'mention',
'revoke', 'attachment_upload', 'attachment_delete', 'bulk_update'
];
/** @var array Allowed entity types for filtering */
private const VALID_ENTITY_TYPES = [
'ticket', 'comment', 'user', 'api_key', 'security',
'template', 'attachment', 'group'
'template', 'attachment', 'ticket_attachments', 'group',
'dependency', 'workflow_transition', 'recurring_ticket', 'custom_field'
];
public function __construct($conn)
@@ -36,12 +41,12 @@ class AuditLogModel
* @param int $limit Requested limit
* @return int Validated limit
*/
private function validateLimit(int $limit): int
private function validateLimit(int $limit, int $max = self::MAX_LIMIT): int
{
if ($limit < 1) {
return self::DEFAULT_LIMIT;
}
return min($limit, self::MAX_LIMIT);
return min($limit, $max);
}
/**
@@ -324,24 +329,44 @@ class AuditLogModel
*/
private function getClientIP()
{
$ipAddress = '';
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '';
// Check for proxy headers
if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
// Cloudflare
$ipAddress = $_SERVER['HTTP_CF_CONNECTING_IP'];
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
// Nginx proxy
$ipAddress = $_SERVER['HTTP_X_REAL_IP'];
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
// Standard proxy header
$ipAddress = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0];
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
// Direct connection
$ipAddress = $_SERVER['REMOTE_ADDR'];
// Forwarded/proxy headers are client-controlled, so only believe them when
// the request actually came from a trusted reverse proxy (same rule as
// RateLimitMiddleware). Otherwise a client could forge its audit-log IP.
$trusted = $GLOBALS['config']['TRUSTED_PROXIES'] ?? [];
if (empty($trusted) || !in_array($remoteAddr, $trusted, true)) {
return trim($remoteAddr);
}
return trim($ipAddress);
// Cloudflare sets CF-Connecting-IP to the real client.
if (
!empty($_SERVER['HTTP_CF_CONNECTING_IP'])
&& filter_var($_SERVER['HTTP_CF_CONNECTING_IP'], FILTER_VALIDATE_IP)
) {
return trim($_SERVER['HTTP_CF_CONNECTING_IP']);
}
// The trusted proxy appends the connecting client to X-Forwarded-For, so
// the RIGHTMOST entry is the IP it observed (any client-supplied prefix is
// not trustworthy).
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
$ip = trim(end($ips));
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
// X-Real-IP is set by the proxy itself.
if (
!empty($_SERVER['HTTP_X_REAL_IP'])
&& filter_var($_SERVER['HTTP_X_REAL_IP'], FILTER_VALIDATE_IP)
) {
return trim($_SERVER['HTTP_X_REAL_IP']);
}
return trim($remoteAddr);
}
/**
@@ -534,7 +559,7 @@ class AuditLogModel
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
WHERE (al.entity_type = 'ticket' AND al.entity_id = ?)
OR (al.entity_type = 'comment' AND JSON_EXTRACT(al.details, '$.ticket_id') = ?)
OR (al.entity_type = 'comment' AND JSON_UNQUOTE(JSON_EXTRACT(al.details, '$.ticket_id')) = ?)
ORDER BY al.created_at DESC"
);
$stmt->bind_param("ss", $ticketId, $ticketId);
@@ -561,10 +586,11 @@ class AuditLogModel
* @param int $offset Offset for pagination
* @return array Array containing logs and total count
*/
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0)
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0, $forExport = false)
{
// Validate pagination parameters
$limit = $this->validateLimit((int)$limit);
// Validate pagination parameters. Exports allow a much higher cap so a
// forensic/compliance CSV isn't silently truncated to the UI page limit.
$limit = $this->validateLimit((int)$limit, $forExport ? self::EXPORT_LIMIT : self::MAX_LIMIT);
$offset = $this->validateOffset((int)$offset);
$whereConditions = [];
+86 -1
View File
@@ -77,6 +77,15 @@ class BulkOperationsModel
$ticketIds = explode(',', $operation['ticket_ids']);
$parameters = $operation['parameters'] ? json_decode($operation['parameters'], true) : [];
// Validate operation parameters up front so invalid values (out-of-range
// priority, unknown status, nonexistent assignee) are rejected cleanly
// instead of corrupting tickets or throwing mid-transaction.
$paramError = $this->validateOperationParameters($operation['operation_type'], is_array($parameters) ? $parameters : []);
if ($paramError !== null) {
return ['processed' => 0, 'failed' => count($ticketIds), 'error' => $paramError];
}
$processed = 0;
$failed = 0;
$errors = [];
@@ -94,12 +103,21 @@ class BulkOperationsModel
// Start transaction for data consistency
$this->conn->begin_transaction();
// Attachment files for deleted tickets are removed only AFTER a successful
// commit, so a rollback can't leave tickets with their files already gone.
$filesToDelete = [];
try {
foreach ($ticketIds as $ticketId) {
$ticketId = trim($ticketId);
$success = false;
try {
// NOTE: bulk_status / bulk_close intentionally do NOT run
// WorkflowModel::isTransitionAllowed(). Bulk operations are an
// admin-only escape hatch for forcing ticket states (e.g. mass
// re-opening), so they bypass the workflow transition rules that
// the single-ticket update path enforces. This is by design.
switch ($operation['operation_type']) {
case 'bulk_close':
// Get current ticket from pre-loaded batch
@@ -200,7 +218,7 @@ class BulkOperationsModel
break;
case 'bulk_delete':
$success = $ticketModel->deleteTicket($ticketId);
$success = $ticketModel->deleteTicket($ticketId, $filesToDelete);
if ($success) {
$auditLogModel->log(
$operation['performed_by'],
@@ -249,6 +267,16 @@ class BulkOperationsModel
// Commit the transaction
$this->conn->commit();
// Now that the DB delete is durable, remove the physical files. Files
// are deleted first; directory entries (no trailing filename) last.
foreach ($filesToDelete as $path) {
if (is_dir($path)) {
@rmdir($path); // only succeeds if empty
} elseif (file_exists($path)) {
@unlink($path);
}
}
} catch (Exception $e) {
// Rollback on any unexpected error
$this->conn->rollback();
@@ -278,6 +306,63 @@ class BulkOperationsModel
return $result;
}
/**
* Validate the parameters for a bulk operation before any ticket is mutated.
*
* @return string|null Error message, or null if the parameters are valid
*/
private function validateOperationParameters(string $type, array $parameters): ?string
{
switch ($type) {
case 'bulk_priority':
if (!isset($parameters['priority'])) {
return 'Missing priority parameter';
}
$priority = $parameters['priority'];
// tickets.priority has a CHECK constraint (between 1 and 6).
if (!is_numeric($priority) || (int)$priority < 1 || (int)$priority > 6) {
return 'Invalid priority: must be between 1 and 6';
}
break;
case 'bulk_status':
if (!isset($parameters['status'])) {
return 'Missing status parameter';
}
$validStatuses = $GLOBALS['config']['TICKET_STATUSES']
?? ['Open', 'Pending', 'In Progress', 'Closed'];
if (!in_array($parameters['status'], $validStatuses, true)) {
return 'Invalid status value';
}
break;
case 'bulk_assign':
if (!isset($parameters['assigned_to'])) {
return 'Missing assigned_to parameter';
}
$assignedTo = $parameters['assigned_to'];
if (!is_numeric($assignedTo) || (int)$assignedTo <= 0 || !$this->userExists((int)$assignedTo)) {
return 'Invalid assigned_to: user does not exist';
}
break;
}
return null;
}
/**
* Check whether a user ID exists.
*/
private function userExists(int $userId): bool
{
$stmt = $this->conn->prepare("SELECT 1 FROM users WHERE user_id = ? LIMIT 1");
$stmt->bind_param("i", $userId);
$stmt->execute();
$exists = $stmt->get_result()->num_rows > 0;
$stmt->close();
return $exists;
}
/**
* Get bulk operation by ID
*
+44 -28
View File
@@ -58,12 +58,12 @@ class CommentModel
/**
* Get total comment count for a ticket
*/
public function getCommentCount(int $ticketId): int
public function getCommentCount(string $ticketId): int
{
$stmt = $this->conn->prepare(
"SELECT COUNT(*) as total FROM ticket_comments WHERE ticket_id = ?"
);
$stmt->bind_param("i", $ticketId);
$stmt->bind_param("s", $ticketId);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
@@ -108,9 +108,9 @@ class CommentModel
$stmt = $this->conn->prepare($sql);
if ($limit > 0) {
$stmt->bind_param("iii", $ticketId, $limit, $offset);
$stmt->bind_param("sii", $ticketId, $limit, $offset);
} else {
$stmt->bind_param("i", $ticketId);
$stmt->bind_param("s", $ticketId);
}
$stmt->execute();
$result = $stmt->get_result();
@@ -146,7 +146,7 @@ class CommentModel
/**
* Paginated threaded comments: fetch one page of root comments + all their replies.
*/
private function getThreadedCommentsPaged(int $ticketId, int $limit, int $offset): array
private function getThreadedCommentsPaged(string $ticketId, int $limit, int $offset): array
{
// Page of root comments
$rootSql = "SELECT tc.*, u.display_name, u.username
@@ -156,7 +156,7 @@ class CommentModel
ORDER BY tc.created_at DESC
LIMIT ? OFFSET ?";
$stmt = $this->conn->prepare($rootSql);
$stmt->bind_param("iii", $ticketId, $limit, $offset);
$stmt->bind_param("sii", $ticketId, $limit, $offset);
$stmt->execute();
$rootResult = $stmt->get_result();
$stmt->close();
@@ -176,27 +176,41 @@ class CommentModel
return [];
}
// All replies for these root comments (up to 3 levels deep)
$placeholders = implode(',', array_fill(0, count($rootIds), '?'));
$replySql = "SELECT tc.*, u.display_name, u.username
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.user_id
WHERE tc.ticket_id = ?
AND tc.parent_comment_id IN ($placeholders)
AND tc.parent_comment_id IS NOT NULL
ORDER BY tc.created_at ASC";
$replyStmt = $this->conn->prepare($replySql);
$types = 'i' . str_repeat('i', count($rootIds));
$replyStmt->bind_param($types, $ticketId, ...$rootIds);
$replyStmt->execute();
$replyResult = $replyStmt->get_result();
$replyStmt->close();
// Load replies level-by-level under this page's roots. A single
// "parent_comment_id IN (rootIds)" only fetches DIRECT children, so
// grandchildren/great-grandchildren (addComment allows up to depth 3)
// would be missing from the map and dropped by buildCommentThread.
// Expand iteratively until no new replies (bounded by max depth 3).
$parentIds = $rootIds;
$depth = 0;
while (!empty($parentIds) && $depth < 3) {
$placeholders = implode(',', array_fill(0, count($parentIds), '?'));
$replySql = "SELECT tc.*, u.display_name, u.username
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.user_id
WHERE tc.ticket_id = ?
AND tc.parent_comment_id IN ($placeholders)
ORDER BY tc.created_at ASC";
$replyStmt = $this->conn->prepare($replySql);
$types = 's' . str_repeat('i', count($parentIds));
$replyStmt->bind_param($types, $ticketId, ...$parentIds);
$replyStmt->execute();
$replyResult = $replyStmt->get_result();
$replyStmt->close();
while ($row = $replyResult->fetch_assoc()) {
$row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User');
$row['replies'] = [];
$row['thread_depth'] = $row['thread_depth'] ?? 1;
$commentMap[$row['comment_id']] = $row;
$nextParentIds = [];
while ($row = $replyResult->fetch_assoc()) {
if (isset($commentMap[$row['comment_id']])) {
continue; // guard against cycles / duplicates
}
$row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User');
$row['replies'] = [];
$row['thread_depth'] = $depth + 1;
$commentMap[$row['comment_id']] = $row;
$nextParentIds[] = $row['comment_id'];
}
$parentIds = $nextParentIds;
$depth++;
}
$rootComments = [];
@@ -380,7 +394,8 @@ class CommentModel
'updated_at' => $hasUpdatedAt ? date('M d, Y H:i') : null
];
} else {
return ['success' => false, 'error' => $this->conn->error];
error_log('CommentModel::updateComment failed: ' . $this->conn->error);
return ['success' => false, 'error' => 'Failed to update comment'];
}
}
@@ -414,7 +429,8 @@ class CommentModel
'ticket_id' => $ticketId
];
} else {
return ['success' => false, 'error' => $this->conn->error];
error_log('CommentModel::deleteComment failed: ' . $this->conn->error);
return ['success' => false, 'error' => 'Failed to delete comment'];
}
}
}
+14 -6
View File
@@ -96,6 +96,10 @@ class CustomFieldModel
(field_name, field_label, field_type, field_options, category, is_required, display_order, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
$isRequired = $data['is_required'] ?? 0;
$displayOrder = $data['display_order'] ?? 0;
$isActive = $data['is_active'] ?? 1;
$stmt = $this->conn->prepare($sql);
$stmt->bind_param(
'sssssiii',
@@ -104,9 +108,9 @@ class CustomFieldModel
$data['field_type'],
$options,
$data['category'],
$data['is_required'] ?? 0,
$data['display_order'] ?? 0,
$data['is_active'] ?? 1
$isRequired,
$displayOrder,
$isActive
);
if ($stmt->execute()) {
@@ -135,6 +139,10 @@ class CustomFieldModel
category = ?, is_required = ?, display_order = ?, is_active = ?
WHERE field_id = ?";
$isRequired = $data['is_required'] ?? 0;
$displayOrder = $data['display_order'] ?? 0;
$isActive = $data['is_active'] ?? 1;
$stmt = $this->conn->prepare($sql);
$stmt->bind_param(
'sssssiiii',
@@ -143,9 +151,9 @@ class CustomFieldModel
$data['field_type'],
$options,
$data['category'],
$data['is_required'] ?? 0,
$data['display_order'] ?? 0,
$data['is_active'] ?? 1,
$isRequired,
$displayOrder,
$isActive,
$fieldId
);
+83 -13
View File
@@ -12,25 +12,67 @@ class DependencyModel
$this->conn = $conn;
}
/**
* Build the extra WHERE fragment (and bound params) that restricts the joined
* ticket alias `t` to tickets the requesting user may see. Reuses
* TicketModel::getVisibilityFilter so the rules stay in one place.
*
* @return array{sql:string,types:string,params:array}
*/
private function buildVisibilityClause($userId, array $userGroups, $isAdmin): array
{
if ($isAdmin) {
return ['sql' => '', 'types' => '', 'params' => []];
}
require_once dirname(__DIR__) . '/models/TicketModel.php';
$ticketModel = new TicketModel($this->conn);
$filter = $ticketModel->getVisibilityFilter([
'user_id' => (int)$userId,
'groups' => implode(',', $userGroups),
'is_admin' => false,
]);
if ($filter['sql'] === '1=1' || $filter['sql'] === '') {
return ['sql' => '', 'types' => '', 'params' => []];
}
return [
'sql' => ' AND ' . $filter['sql'],
'types' => $filter['types'],
'params' => $filter['params'],
];
}
/**
* Get all dependencies for a ticket
*
* The linked ticket's title/status/priority are only returned for tickets the
* requesting user is allowed to see (same rules as TicketModel::getVisibilityFilter).
* With the default (null user, non-admin) only public tickets are exposed.
*
* @param string $ticketId Ticket ID
* @param int|null $userId Requesting user's ID (null = anonymous)
* @param array $userGroups Requesting user's group names
* @param bool $isAdmin Whether the requesting user is an admin (bypasses filtering)
* @return array Dependencies grouped by type
*/
public function getDependencies($ticketId)
public function getDependencies($ticketId, $userId = null, array $userGroups = [], $isAdmin = false)
{
$visibility = $this->buildVisibilityClause($userId, $userGroups, $isAdmin);
$sql = "SELECT d.*, t.title, t.status, t.priority
FROM ticket_dependencies d
LEFT JOIN tickets t ON d.depends_on_id = t.ticket_id
WHERE d.ticket_id = ?
WHERE d.ticket_id = ?" . $visibility['sql'] . "
ORDER BY d.dependency_type, d.created_at DESC";
$stmt = $this->conn->prepare($sql);
if (!$stmt) {
throw new Exception('Prepare failed: ' . $this->conn->error);
}
$stmt->bind_param("s", $ticketId);
$types = 's' . $visibility['types'];
$stmt->bind_param($types, $ticketId, ...$visibility['params']);
if (!$stmt->execute()) {
throw new Exception('Execute failed: ' . $stmt->error);
}
@@ -54,22 +96,32 @@ class DependencyModel
/**
* Get tickets that depend on this ticket
*
* The linked ticket's title/status/priority are only returned for tickets the
* requesting user is allowed to see (same rules as TicketModel::getVisibilityFilter).
* With the default (null user, non-admin) only public tickets are exposed.
*
* @param string $ticketId Ticket ID
* @param int|null $userId Requesting user's ID (null = anonymous)
* @param array $userGroups Requesting user's group names
* @param bool $isAdmin Whether the requesting user is an admin (bypasses filtering)
* @return array Dependent tickets
*/
public function getDependentTickets($ticketId)
public function getDependentTickets($ticketId, $userId = null, array $userGroups = [], $isAdmin = false)
{
$visibility = $this->buildVisibilityClause($userId, $userGroups, $isAdmin);
$sql = "SELECT d.*, t.title, t.status, t.priority
FROM ticket_dependencies d
LEFT JOIN tickets t ON d.ticket_id = t.ticket_id
WHERE d.depends_on_id = ?
WHERE d.depends_on_id = ?" . $visibility['sql'] . "
ORDER BY d.dependency_type, d.created_at DESC";
$stmt = $this->conn->prepare($sql);
if (!$stmt) {
throw new Exception('Prepare failed: ' . $this->conn->error);
}
$stmt->bind_param("s", $ticketId);
$types = 's' . $visibility['types'];
$stmt->bind_param($types, $ticketId, ...$visibility['params']);
if (!$stmt->execute()) {
throw new Exception('Execute failed: ' . $stmt->error);
}
@@ -190,14 +242,25 @@ class DependencyModel
*/
private function wouldCreateCycle($ticketId, $dependsOnId, $type): bool
{
// Only check for cycles in blocking relationships
// Only blocking relationships impose an ordering that can form a cycle.
if (!in_array($type, ['blocks', 'blocked_by'])) {
return false;
}
// Check if dependsOnId already has ticketId in its dependency chain
// Normalize the new row to a precedence edge "from must finish before to":
// (t, d, 'blocks') => t blocks d => edge t -> d
// (t, d, 'blocked_by') => t blocked_by d => edge d -> t
if ($type === 'blocks') {
$from = $ticketId;
$to = $dependsOnId;
} else { // blocked_by
$from = $dependsOnId;
$to = $ticketId;
}
// Adding edge from->to creates a cycle iff a path to ->* from already exists.
$visited = [];
return $this->hasDependencyPath($dependsOnId, $ticketId, $visited, 0);
return $this->hasDependencyPath($to, $from, $visited, 0);
}
/**
@@ -236,15 +299,22 @@ class DependencyModel
$visited[] = $source;
$sql = "SELECT depends_on_id FROM ticket_dependencies
WHERE ticket_id = ? AND dependency_type IN ('blocks', 'blocked_by')";
// Walk the unified precedence graph forward from $source. Both directions
// of expression contribute an outgoing edge "$source must finish before X":
// blocks rows where ticket_id=$source -> X = depends_on_id
// blocked_by rows where depends_on_id=$source -> X = ticket_id
$sql = "SELECT depends_on_id AS next_id FROM ticket_dependencies
WHERE ticket_id = ? AND dependency_type = 'blocks'
UNION
SELECT ticket_id AS next_id FROM ticket_dependencies
WHERE depends_on_id = ? AND dependency_type = 'blocked_by'";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $source);
$stmt->bind_param("ss", $source, $source);
$stmt->execute();
$result = $stmt->get_result();
while ($row = $result->fetch_assoc()) {
if ($this->hasDependencyPath($row['depends_on_id'], $target, $visited, $depth + 1)) {
if ($this->hasDependencyPath($row['next_id'], $target, $visited, $depth + 1)) {
$stmt->close();
return true;
}
+39 -1
View File
@@ -65,7 +65,7 @@ class RecurringTicketModel
$stmt = $this->conn->prepare($sql);
$stmt->bind_param(
'ssssiiisssii',
'ssssiissssii',
$data['title_template'],
$data['description_template'],
$data['category'],
@@ -151,6 +151,44 @@ class RecurringTicketModel
return $items;
}
/**
* Atomically claim a due schedule for processing.
*
* Advances next_run_at (and stamps last_run_at) in a single conditional
* UPDATE gated on the row still being active and due. Returns true only if
* THIS call won the claim. This must be done BEFORE creating the ticket so
* that:
* - two overlapping cron runs can't both process the same schedule, and
* - a failure in a later step (ticket create, assignment, audit) can't
* leave next_run_at in the past, which would re-fire and re-create a
* duplicate ticket on every subsequent cron run.
*
* @return bool true if the schedule was claimed by this call
*/
public function claimForRun($recurringId)
{
$recurring = $this->getById($recurringId);
if (!$recurring) {
return false;
}
$nextRun = $this->calculateNextRunTime(
$recurring['schedule_type'],
$recurring['schedule_day'],
$recurring['schedule_time']
);
$sql = "UPDATE recurring_tickets
SET last_run_at = NOW(), next_run_at = ?
WHERE recurring_id = ? AND is_active = 1 AND next_run_at <= NOW()";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('si', $nextRun, $recurringId);
$stmt->execute();
$claimed = $stmt->affected_rows > 0;
$stmt->close();
return $claimed;
}
/**
* Update last run and calculate next run time
*/
+23 -8
View File
@@ -28,8 +28,14 @@ class StatsModel
/**
* Get tickets by assignee (top 5)
*/
public function getTicketsByAssignee(int $limit = 8): array
public function getTicketsByAssignee(int $limit = 8, array $visFilter = []): array
{
// Apply the same visibility filter as the rest of the stats so a non-admin's
// assignee widget doesn't count (and thereby leak) confidential tickets.
$visSQL = $visFilter['sql'] ?? '';
$visParams = $visFilter['params'] ?? [];
$visTypes = $visFilter['types'] ?? '';
$sql = "SELECT
u.user_id,
u.display_name,
@@ -37,12 +43,20 @@ class StatsModel
COUNT(t.ticket_id) as open_count
FROM tickets t
LEFT JOIN users u ON t.assigned_to = u.user_id
WHERE t.status != 'Closed' AND t.assigned_to IS NOT NULL
GROUP BY t.assigned_to
ORDER BY open_count DESC
LIMIT ?";
WHERE t.status != 'Closed' AND t.assigned_to IS NOT NULL";
if ($visSQL !== '') {
$sql .= " AND ($visSQL)";
}
$sql .= " GROUP BY t.assigned_to
ORDER BY open_count DESC
LIMIT ?";
$params = $visParams;
$params[] = $limit;
$types = $visTypes . 'i';
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $limit);
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
$data = [];
@@ -173,8 +187,9 @@ class StatsModel
// Sort priority keys
ksort($byPriority);
// Query 3: Get assignee stats (requires JOIN, kept separate)
$byAssignee = $this->getTicketsByAssignee();
// Query 3: Get assignee stats (requires JOIN, kept separate). Pass the same
// visibility filter so confidential tickets aren't counted for non-admins.
$byAssignee = $this->getTicketsByAssignee(8, $visFilter);
return [
'open_tickets' => (int)($counts['open_tickets'] ?? 0),
+87 -37
View File
@@ -9,7 +9,7 @@ class TicketModel
$this->conn = $conn;
}
public function getTicketById(int $id): ?array
public function getTicketById(string $id): ?array
{
$sql = "SELECT t.*,
u_created.username as creator_username,
@@ -24,7 +24,7 @@ class TicketModel
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
WHERE t.ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $id);
$stmt->bind_param("s", $id);
$stmt->execute();
$result = $stmt->get_result();
@@ -82,18 +82,22 @@ class TicketModel
$paramTypes .= str_repeat('s', count($types));
}
// Search Functionality — use FULLTEXT when available, fall back to LIKE
if ($search && !empty($search)) {
if ($this->hasFulltextIndex()) {
// Search Functionality — use FULLTEXT when available, fall back to LIKE.
// Use a strict emptiness check so a literal "0" search is honored.
if ($search !== null && $search !== '') {
// Strip MySQL boolean mode special chars to prevent parse errors on user input
$ftSearch = trim(preg_replace('/\s+/', ' ', preg_replace('/[+\-><()\~*"@]+/', ' ', $search)));
if ($this->hasFulltextIndex() && $ftSearch !== '') {
// MATCH...AGAINST for indexed full-text search (much faster at scale)
// Strip MySQL boolean mode special chars to prevent parse errors on user input
$ftSearch = preg_replace('/[+\-><()\~*"@]+/', ' ', $search);
$ftSearch = trim(preg_replace('/\s+/', ' ', $ftSearch)) . '*';
$ftSearch .= '*';
$whereConditions[] = "(MATCH(t.title, t.description) AGAINST (? IN BOOLEAN MODE) OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
$searchTerm = "%$search%";
$params = array_merge($params, [$ftSearch, $searchTerm, $searchTerm, $searchTerm]);
$paramTypes .= 'ssss';
} else {
// No FULLTEXT index, or the sanitized boolean query is empty (search was
// only special chars) — fall back to LIKE instead of emitting invalid
// AGAINST('*' ...) syntax.
$whereConditions[] = "(t.title LIKE ? OR t.description LIKE ? OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
$searchTerm = "%$search%";
$params = array_merge($params, [$searchTerm, $searchTerm, $searchTerm, $searchTerm, $searchTerm]);
@@ -208,6 +212,11 @@ class TicketModel
ORDER BY $sortExpression $sortDirection
LIMIT ? OFFSET ?";
// Keep a copy of the filter params (without LIMIT/OFFSET) for the
// fallback COUNT below.
$countParams = $params;
$countParamTypes = $paramTypes;
$params[] = $limit;
$params[] = $offset;
$paramTypes .= 'ii';
@@ -228,6 +237,24 @@ class TicketModel
}
$stmt->close();
// COUNT(*) OVER() rides on returned rows, so a page past the last row
// yields zero rows and a bogus total of 0. Fall back to a direct COUNT
// so the total/pages stay correct for stale or over-range page links.
if ($totalTickets === 0 && $offset > 0) {
$countSql = "SELECT COUNT(*) AS c
FROM tickets t
LEFT JOIN users u_created ON t.created_by = u_created.user_id
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
$whereClause";
$countStmt = $this->conn->prepare($countSql);
if (!empty($countParams)) {
$countStmt->bind_param($countParamTypes, ...$countParams);
}
$countStmt->execute();
$totalTickets = (int)($countStmt->get_result()->fetch_assoc()['c'] ?? 0);
$countStmt->close();
}
return [
'tickets' => $tickets,
'total' => $totalTickets,
@@ -285,7 +312,7 @@ class TicketModel
if ($expectedUpdatedAt !== null) {
$stmt->bind_param(
"sissssisis",
"sissssisss",
$ticketData['title'],
$ticketData['priority'],
$ticketData['status'],
@@ -299,7 +326,7 @@ class TicketModel
);
} else {
$stmt->bind_param(
"sissssisi",
"sissssiss",
$ticketData['title'],
$ticketData['priority'],
$ticketData['status'],
@@ -320,20 +347,31 @@ class TicketModel
return ['success' => false, 'error' => 'Database error: ' . $this->conn->error, 'conflict' => false];
}
// Check for optimistic locking conflict
if ($expectedUpdatedAt !== null && $affectedRows === 0) {
// Either ticket doesn't exist or was modified by someone else
// Zero affected rows is ambiguous: the ticket may not exist, an optimistic
// lock may have failed, or the row simply matched with no column changes
// (identical resubmit). Disambiguate so we neither report a false conflict
// nor silently "succeed" on a non-existent ticket.
if ($affectedRows === 0) {
$ticket = $this->getTicketById($ticketData['ticket_id']);
if ($ticket) {
return [
'success' => false,
'error' => 'This ticket was modified by another user. Please refresh and try again.',
'conflict' => true,
'current_updated_at' => $ticket['updated_at']
];
} else {
if (!$ticket) {
return ['success' => false, 'error' => 'Ticket not found', 'conflict' => false];
}
if ($expectedUpdatedAt !== null) {
// Only a genuine concurrent modification changes updated_at. If it
// still equals the expected value the WHERE matched but nothing
// changed (e.g. identical data resubmitted within the same second),
// which is not a conflict.
if ($ticket['updated_at'] !== $expectedUpdatedAt) {
return [
'success' => false,
'error' => 'This ticket was modified by another user. Please refresh and try again.',
'conflict' => true,
'current_updated_at' => $ticket['updated_at']
];
}
}
// Ticket exists and no conflict: treat no-op update as success.
}
return ['success' => true, 'error' => null, 'conflict' => false];
@@ -493,9 +531,9 @@ class TicketModel
}
}
public function addComment(int $ticketId, array $commentData): array
public function addComment(string $ticketId, array $commentData): array
{
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
VALUES (?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
@@ -505,7 +543,7 @@ class TicketModel
$markdownEnabled = $commentData['markdown_enabled'] ? 1 : 0;
$stmt->bind_param(
"issi",
"sssi",
$ticketId,
$username,
$commentData['comment_text'],
@@ -534,11 +572,11 @@ class TicketModel
* @param int $assignedBy User ID performing the assignment
* @return bool Success status
*/
public function assignTicket(int $ticketId, int $userId, int $assignedBy): bool
public function assignTicket(string $ticketId, int $userId, int $assignedBy): bool
{
$sql = "UPDATE tickets SET assigned_to = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("iii", $userId, $assignedBy, $ticketId);
$stmt->bind_param("iis", $userId, $assignedBy, $ticketId);
$result = $stmt->execute();
$stmt->close();
return $result;
@@ -551,11 +589,11 @@ class TicketModel
* @param int $updatedBy User ID performing the unassignment
* @return bool Success status
*/
public function unassignTicket(int $ticketId, int $updatedBy): bool
public function unassignTicket(string $ticketId, int $updatedBy): bool
{
$sql = "UPDATE tickets SET assigned_to = NULL, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ii", $updatedBy, $ticketId);
$stmt->bind_param("is", $updatedBy, $ticketId);
$result = $stmt->execute();
$stmt->close();
return $result;
@@ -710,7 +748,7 @@ class TicketModel
* @param int $updatedBy User ID
* @return bool
*/
public function updateVisibility(int $ticketId, string $visibility, ?string $visibilityGroups, int $updatedBy): bool
public function updateVisibility(string $ticketId, string $visibility, ?string $visibilityGroups, int $updatedBy): bool
{
$allowedVisibilities = ['public', 'internal', 'confidential'];
if (!in_array($visibility, $allowedVisibilities)) {
@@ -729,7 +767,7 @@ class TicketModel
$sql = "UPDATE tickets SET visibility = ?, visibility_groups = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ssii", $visibility, $visibilityGroups, $updatedBy, $ticketId);
$stmt->bind_param("ssis", $visibility, $visibilityGroups, $updatedBy, $ticketId);
$result = $stmt->execute();
$stmt->close();
return $result;
@@ -740,9 +778,13 @@ class TicketModel
* Admin-only operation. Removes comments, attachments, watchers, dependencies.
*
* @param string $ticketId Ticket ID
* @param array|null &$deferredFiles When provided, attachment file paths to
* remove are appended here instead of being unlinked immediately, so a
* caller running inside a DB transaction can delete them only AFTER a
* successful commit (avoids destroying files for a rolled-back delete).
* @return bool Success status
*/
public function deleteTicket(string $ticketId): bool
public function deleteTicket(string $ticketId, ?array &$deferredFiles = null): bool
{
// Collect attachment filenames before deleting DB rows
$attachmentFiles = [];
@@ -763,7 +805,7 @@ class TicketModel
"DELETE FROM ticket_watchers WHERE ticket_id = ?",
"DELETE FROM ticket_dependencies WHERE ticket_id = ? OR depends_on_id = ?",
"DELETE FROM ticket_attachments WHERE ticket_id = ?",
"DELETE FROM ticket_custom_fields WHERE ticket_id = ?",
"DELETE FROM custom_field_values WHERE ticket_id = ?",
];
foreach ($children as $sql) {
@@ -804,13 +846,21 @@ class TicketModel
: (isset($GLOBALS['config']['UPLOAD_DIR']) ? $GLOBALS['config']['UPLOAD_DIR'] : dirname(__DIR__) . '/uploads');
$ticketDir = rtrim($uploadDir, '/') . '/' . $ticketId;
if (is_dir($ticketDir)) {
foreach ($attachmentFiles as $filename) {
$file = $ticketDir . '/' . basename($filename);
if (file_exists($file)) {
@unlink($file);
if ($deferredFiles !== null) {
// Defer physical deletion to the caller (post-commit).
foreach ($attachmentFiles as $filename) {
$deferredFiles[] = $ticketDir . '/' . basename($filename);
}
$deferredFiles[] = $ticketDir; // dir removed last, only if empty
} else {
foreach ($attachmentFiles as $filename) {
$file = $ticketDir . '/' . basename($filename);
if (file_exists($file)) {
@unlink($file);
}
}
@rmdir($ticketDir); // Remove dir only if empty
}
@rmdir($ticketDir); // Remove dir only if empty
}
return true;
}
+3 -3
View File
@@ -86,7 +86,7 @@ class UserModel
$user = $result->fetch_assoc();
$updateStmt = $this->conn->prepare(
"UPDATE users SET display_name = ?, email = ?, groups = ?, is_admin = ?, last_login = NOW() WHERE username = ?"
"UPDATE users SET display_name = ?, email = ?, `groups` = ?, is_admin = ?, last_login = NOW() WHERE username = ?"
);
$updateStmt->bind_param("sssis", $displayName, $email, $groups, $isAdmin, $username);
$updateStmt->execute();
@@ -100,7 +100,7 @@ class UserModel
} else {
// Create new user
$insertStmt = $this->conn->prepare(
"INSERT INTO users (username, display_name, email, groups, is_admin, last_login) VALUES (?, ?, ?, ?, ?, NOW())"
"INSERT INTO users (username, display_name, email, `groups`, is_admin, last_login) VALUES (?, ?, ?, ?, ?, NOW())"
);
$insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin);
$insertStmt->execute();
@@ -300,7 +300,7 @@ class UserModel
return $cached;
}
$stmt = $this->conn->prepare("SELECT DISTINCT groups FROM users WHERE groups IS NOT NULL AND groups != ''");
$stmt = $this->conn->prepare("SELECT DISTINCT `groups` FROM users WHERE `groups` IS NOT NULL AND `groups` != ''");
$stmt->execute();
$result = $stmt->get_result();
+66 -37
View File
@@ -26,31 +26,38 @@ class WorkflowModel
*/
private function getAllTransitions(): array
{
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_transitions', function () {
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
FROM status_transitions
WHERE is_active = TRUE";
$result = $this->conn->query($sql);
$cached = CacheHelper::get(self::$CACHE_PREFIX, 'all_transitions', self::$CACHE_TTL);
if ($cached !== null) {
return $cached;
}
if (!$result) {
return [];
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
FROM status_transitions
WHERE is_active = TRUE";
$result = $this->conn->query($sql);
if (!$result) {
// A transient DB failure must NOT be cached as "no transitions" — that
// would block every status change for the whole TTL. Fail safe by
// returning empty without storing it, so the next call retries.
return [];
}
$transitions = [];
while ($row = $result->fetch_assoc()) {
$from = $row['from_status'];
if (!isset($transitions[$from])) {
$transitions[$from] = [];
}
$transitions[$from][$row['to_status']] = [
'to_status' => $row['to_status'],
'requires_comment' => (bool)$row['requires_comment'],
'requires_admin' => (bool)$row['requires_admin']
];
}
$transitions = [];
while ($row = $result->fetch_assoc()) {
$from = $row['from_status'];
if (!isset($transitions[$from])) {
$transitions[$from] = [];
}
$transitions[$from][$row['to_status']] = [
'to_status' => $row['to_status'],
'requires_comment' => (bool)$row['requires_comment'],
'requires_admin' => (bool)$row['requires_admin']
];
}
return $transitions;
}, self::$CACHE_TTL);
CacheHelper::set(self::$CACHE_PREFIX, 'all_transitions', $transitions);
return $transitions;
}
/**
@@ -107,24 +114,29 @@ class WorkflowModel
*/
public function getAllStatuses(): array
{
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_statuses', function () {
$sql = "SELECT DISTINCT from_status as status FROM status_transitions
UNION
SELECT DISTINCT to_status as status FROM status_transitions
ORDER BY status";
$result = $this->conn->query($sql);
$cached = CacheHelper::get(self::$CACHE_PREFIX, 'all_statuses', self::$CACHE_TTL);
if ($cached !== null) {
return $cached;
}
if (!$result) {
return [];
}
$sql = "SELECT DISTINCT from_status as status FROM status_transitions
UNION
SELECT DISTINCT to_status as status FROM status_transitions
ORDER BY status";
$result = $this->conn->query($sql);
$statuses = [];
while ($row = $result->fetch_assoc()) {
$statuses[] = $row['status'];
}
if (!$result) {
// Do not cache an empty list on a transient DB failure.
return [];
}
return $statuses;
}, self::$CACHE_TTL);
$statuses = [];
while ($row = $result->fetch_assoc()) {
$statuses[] = $row['status'];
}
CacheHelper::set(self::$CACHE_PREFIX, 'all_statuses', $statuses);
return $statuses;
}
/**
@@ -149,6 +161,23 @@ class WorkflowModel
];
}
/**
* Whether a given transition requires a comment.
*
* Convenience accessor so callers (e.g. the update-ticket endpoint) can
* enforce requires_comment server-side without inspecting the full row.
* Returns false for an undefined transition or a no-op (same status).
*
* @param string $fromStatus Current status
* @param string $toStatus Desired status
* @return bool True if the transition requires a comment
*/
public function transitionRequiresComment(string $fromStatus, string $toStatus): bool
{
$requirements = $this->getTransitionRequirements($fromStatus, $toStatus);
return $requirements !== null && !empty($requirements['requires_comment']);
}
/**
* Clear workflow cache (call when transitions are modified)
*/
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env php
<?php
/**
* Verify the running PHP environment meets the declared runtime requirements.
*
* Reads config/requirements.php and checks the PHP version and that every
* required extension is loaded. Exits non-zero (failing CI) on any miss.
*
* Usage: php scripts/check_requirements.php
*/
$req = require __DIR__ . '/../config/requirements.php';
$errors = [];
// PHP version
$minPhp = $req['min_php_version'];
if (version_compare(PHP_VERSION, $minPhp, '<')) {
$errors[] = sprintf('PHP %s is below the required minimum %s', PHP_VERSION, $minPhp);
}
// Required extensions
foreach ($req['required_extensions'] as $ext) {
if (!extension_loaded($ext)) {
$errors[] = sprintf('Missing required PHP extension: %s', $ext);
}
}
if (!empty($errors)) {
fwrite(STDERR, "Requirement check FAILED:\n");
foreach ($errors as $err) {
fwrite(STDERR, ' - ' . $err . "\n");
}
exit(1);
}
printf(
"Requirement check passed: PHP %s (>= %s); extensions: %s\n",
PHP_VERSION,
$minPhp,
implode(', ', $req['required_extensions'])
);
exit(0);
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env php
<?php
/**
* Orphan Upload Cleanup
*
* Removes files under uploads/<ticketId>/ that have NO matching row in
* ticket_attachments (e.g. leftovers from a failed DB insert). Intended to be
* run from cron:
* 0 4 * * * /usr/bin/php /path/to/scripts/cleanup_orphan_uploads.php >> /var/log/orphan_uploads.log 2>&1
*
* SAFETY:
* - Only files older than a grace period (GRACE_SECONDS, default 24h) are
* considered, so a freshly written file whose DB row has not been inserted
* yet (in-flight upload) is never deleted.
* - Only 9-digit ticket directories are scanned. uploads/avatars/ (and any
* other non-ticket directory) is skipped entirely.
* - A file is deleted only when no ticket_attachments row references its
* stored filename (looked up with a prepared statement).
*
* Usage:
* php cleanup_orphan_uploads.php # delete orphaned files past grace period
* php cleanup_orphan_uploads.php --dry-run # report only, delete nothing
*/
// Prevent web access
if (php_sapi_name() !== 'cli') {
http_response_code(403);
exit('CLI access only');
}
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
/** Files younger than this (seconds) are never touched — protects in-flight uploads. */
const GRACE_SECONDS = 86400;
$dryRun = in_array('--dry-run', $argv, true);
function logMessage($message)
{
echo '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n";
}
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? (dirname(__DIR__) . '/uploads');
$uploadRoot = realpath($uploadDir);
if ($uploadRoot === false || !is_dir($uploadRoot)) {
logMessage("Upload directory not found: {$uploadDir}");
exit(0);
}
logMessage('Starting orphan upload cleanup' . ($dryRun ? ' (DRY RUN)' : ''));
try {
$conn = Database::getConnection();
} catch (Exception $e) {
logMessage('FATAL ERROR: could not connect to database: ' . $e->getMessage());
exit(1);
}
// Prepared lookup: does any attachment row reference this stored filename?
// Stored filenames are globally unique (uniqid), so filename alone is sufficient
// and safe — a match in any ticket means the file is a real attachment.
$lookup = $conn->prepare('SELECT 1 FROM ticket_attachments WHERE filename = ? LIMIT 1');
if ($lookup === false) {
logMessage('FATAL ERROR: could not prepare lookup statement: ' . $conn->error);
exit(1);
}
$now = time();
$scanned = 0;
$orphaned = 0;
$deleted = 0;
$skippedTooNew = 0;
$errors = 0;
foreach (new DirectoryIterator($uploadRoot) as $entry) {
if ($entry->isDot() || !$entry->isDir() || $entry->isLink()) {
continue;
}
// Ticket directories are 9-digit ticket IDs. Skip avatars/ and anything else.
$dirName = $entry->getFilename();
if (!preg_match('/^\d{9}$/', $dirName)) {
continue;
}
foreach (new DirectoryIterator($entry->getPathname()) as $file) {
if ($file->isDot() || !$file->isFile() || $file->isLink()) {
continue;
}
$scanned++;
$filename = $file->getFilename();
// Never touch files younger than the grace period (in-flight uploads).
$age = $now - $file->getMTime();
if ($age < GRACE_SECONDS) {
$skippedTooNew++;
continue;
}
// Keep the file if any attachment row references it.
$lookup->bind_param('s', $filename);
$lookup->execute();
$hasRow = $lookup->get_result()->num_rows > 0;
if ($hasRow) {
continue;
}
$orphaned++;
$path = $file->getPathname();
if ($dryRun) {
logMessage("WOULD DELETE orphan: {$dirName}/{$filename}");
continue;
}
if (@unlink($path)) {
$deleted++;
logMessage("Deleted orphan: {$dirName}/{$filename}");
} else {
$errors++;
logMessage("ERROR: could not delete: {$dirName}/{$filename}");
}
}
}
$lookup->close();
Database::close();
logMessage('Cleanup complete' . ($dryRun ? ' (DRY RUN — nothing deleted)' : '') . ':');
logMessage(" - Scanned: {$scanned} files");
logMessage(" - Orphaned: {$orphaned} files");
logMessage(" - Deleted: {$deleted} files");
logMessage(" - Skipped (too new): {$skippedTooNew} files");
if ($errors > 0) {
logMessage(" - Errors: {$errors} files");
}
exit($errors > 0 ? 1 : 0);
+5 -5
View File
@@ -48,14 +48,14 @@ if (!empty($_GET['priority'])) {
}
}
if (!empty($_GET['category'])) {
$activeFilters[] = ['type' => 'category', 'value' => $_GET['category'], 'label' => 'Category: ' . htmlspecialchars($_GET['category'])];
$activeFilters[] = ['type' => 'category', 'value' => $_GET['category'], 'label' => 'Category: ' . $_GET['category']];
}
if (!empty($_GET['type'])) {
$activeFilters[] = ['type' => 'type', 'value' => $_GET['type'], 'label' => 'Type: ' . htmlspecialchars($_GET['type'])];
$activeFilters[] = ['type' => 'type', 'value' => $_GET['type'], 'label' => 'Type: ' . $_GET['type']];
}
if (!empty($_GET['assigned_to'])) {
$label = match ($_GET['assigned_to']) {
'unassigned' => 'Unassigned', 'me' => 'Me', default => 'User #' . htmlspecialchars($_GET['assigned_to'])
'unassigned' => 'Unassigned', 'me' => 'Me', default => 'User #' . $_GET['assigned_to']
};
$activeFilters[] = ['type' => 'assigned_to', 'value' => $_GET['assigned_to'], 'label' => 'Assigned: ' . $label];
}
@@ -1317,7 +1317,7 @@ if (advForm) advForm.addEventListener('submit', function(e) {
var pLabels = { '1':'P1 — Critical', '2':'P2 — High', '3':'P3 — Medium', '4':'P4 — Low', '5':'P5 — Minimal' };
var dotClass = { 'Open':'lt-dot-up', 'In Progress':'lt-dot-warn', 'Pending':'lt-dot--orange', 'Closed':'lt-dot-idle' };
function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
function fmtAge(dateStr) {
var d = new Date(dateStr);
@@ -1342,7 +1342,7 @@ if (advForm) advForm.addEventListener('submit', function(e) {
var o = hasCheckbox ? 1 : 0; // column offset for checkbox col
var priority = cells[1 + o] ? cells[1 + o].textContent.trim() : '';
var title = cells[2 + o] ? cells[2 + o].querySelector('.ticket-link')?.textContent.trim() || '' : '';
var title = cells[2 + o] ? cells[2 + o].textContent.trim() : '';
var category = cells[3 + o] ? cells[3 + o].textContent.trim() : '';
var typeVal = cells[4 + o] ? cells[4 + o].textContent.trim() : '';
var status = cells[5 + o] ? cells[5 + o].textContent.trim().replace(/^\s*●\s*/, '') : '';
+51 -62
View File
@@ -215,56 +215,58 @@ include __DIR__ . '/layout_header.php';
1 => 8, 2 => 24, default => 72
};
$elapsedSeconds = time() - strtotime($ticket['created_at']);
$elapsedHours = round($elapsedSeconds / 3600, 1);
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
$slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600);
$alertClass = $priorityNum === 1 ? 'lt-alert--error' : 'lt-alert--warning';
$alertIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
$alertLabel = $priorityNum === 1 ? 'CRITICAL — P1 Ticket' : 'HIGH PRIORITY — P2 Ticket';
$progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progress--red' : 'lt-progress--green');
$slaClass = $priorityNum === 1 ? 'lt-sla-p1' : 'lt-sla-p2';
$slaIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
$slaLabel = $priorityNum === 1 ? 'P1 Critical' : 'P2 High';
$slaId = 'sla-' . htmlspecialchars($ticket['ticket_id'], ENT_QUOTES, 'UTF-8');
?>
<!-- Priority alert banner P1/P2 only, dismissible per session -->
<div class="lt-alert <?= $alertClass ?>" id="priorityAlertBanner"
role="alert" aria-live="polite"
data-alert-id="priority-banner-<?= htmlspecialchars($ticket['ticket_id']) ?>"
<!-- SLA banner P1/P2 only, dismissible per session -->
<div class="<?= $slaClass ?>" id="priorityAlertBanner" role="alert" aria-live="polite"
data-sla-id="<?= $slaId ?>"
data-created-at="<?= (int)strtotime($ticket['created_at']) ?>"
data-sla-hours="<?= $slaTargetHours ?>"
style="margin-bottom:0.75rem">
<span class="lt-alert-icon" aria-hidden="true"><?= $alertIcon ?></span>
<div class="lt-alert-body">
<div class="lt-alert-title"><?= $alertLabel ?></div>
<div class="lt-alert-msg">
SLA target: <strong><?= $slaTargetHours ?>h</strong> &mdash;
Elapsed: <strong id="slaElapsedTimer"><?= $elapsedHours ?>h</strong>
<?php if (!$slaBreached) : ?>
&mdash; Remaining: <strong id="slaCountdownTimer" class="lt-text-cyan"></strong>
<?php else : ?>
&mdash; <span class="lt-text-danger" id="slaCountdownTimer">SLA BREACHED (+<strong id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</strong>)</span>
<span class="lt-sla-icon" aria-hidden="true"><?= $slaIcon ?></span>
<div class="lt-sla-info">
<div class="lt-sla-title">
<?= $slaLabel ?> — SLA: <span id="slaElapsedTimer"></span> elapsed of <?= $slaTargetHours ?>h limit
<?php if ($slaBreached) : ?>
&nbsp;<span class="lt-text-danger" id="slaBreachLabel">BREACHED</span>
<?php endif ?>
<div class="lt-progress lt-progress--sm <?= $progressClass ?>" id="slaProgress" style="margin-top:0.35rem"
aria-label="SLA progress <?= $slaPct ?>%">
<div class="lt-progress-bar" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div>
</div>
</div>
<div class="lt-sla-bar" aria-label="SLA progress <?= $slaPct ?>%" id="slaProgress">
<div class="lt-sla-fill" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div>
</div>
</div>
<button type="button" class="lt-alert-close" data-action="dismiss-priority-banner" aria-label="Dismiss">&#x2715;</button>
<?php if (!$slaBreached) : ?>
<div class="lt-sla-meta" id="slaCountdownTimer"></div>
<?php else : ?>
<div class="lt-sla-meta lt-text-danger" id="slaCountdownTimer">+<span id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</span> over</div>
<?php endif ?>
<button type="button" class="lt-sla-dismiss" aria-label="Dismiss">&#x2715;</button>
</div>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
(function(){
var banner = document.getElementById('priorityAlertBanner');
var id = 'priority-banner-<?= htmlspecialchars($ticket['ticket_id']) ?>';
try { if(sessionStorage.getItem('lt_dismissed_'+id)) banner.classList.add('dismissed'); } catch(e) {}
var id = banner.dataset.slaId;
try { if (id && sessionStorage.getItem('lt_sla_dismissed_' + id)) banner.hidden = true; } catch(e) {}
banner.querySelector('.lt-sla-dismiss').addEventListener('click', function() {
banner.hidden = true;
try { if (id) sessionStorage.setItem('lt_sla_dismissed_' + id, '1'); } catch(e) {}
});
// Live SLA timers — start after base.js initialises lt
document.addEventListener('DOMContentLoaded', function() {
if (!banner || banner.classList.contains('dismissed')) return;
if (banner.hidden) return;
var createdAt = parseInt(banner.dataset.createdAt, 10) * 1000;
var slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
var deadline = new Date(createdAt + slaMs);
var elapsedEl = document.getElementById('slaElapsedTimer');
var slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
var deadline = new Date(createdAt + slaMs);
var elapsedEl = document.getElementById('slaElapsedTimer');
var countdownEl = document.getElementById('slaCountdownTimer');
var overrunEl = document.getElementById('slaOverrunTimer');
var progressBar = document.getElementById('slaProgressBar');
var overrunEl = document.getElementById('slaOverrunTimer');
var fillBar = document.getElementById('slaProgressBar');
var progressWrap = document.getElementById('slaProgress');
function fmtHMS(ms) {
@@ -274,35 +276,19 @@ include __DIR__ . '/layout_header.php';
}
function tick() {
var now = Date.now();
var elapsed = now - createdAt;
var now = Date.now();
var elapsed = now - createdAt;
var remaining = deadline - now;
var pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
var pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
if (progressBar) progressBar.style.width = pct + '%';
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
if (fillBar) fillBar.style.width = pct + '%';
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
if (remaining > 0) {
// SLA not yet breached
if (countdownEl) {
countdownEl.textContent = fmtHMS(remaining) + ' remaining';
countdownEl.className = pct >= 75 ? 'lt-text-danger' : 'lt-text-cyan';
}
if (progressWrap && pct >= 75) {
progressWrap.className = progressWrap.className.replace('lt-progress--green','lt-progress--red');
}
if (countdownEl) countdownEl.textContent = fmtHMS(remaining) + ' remaining';
} else {
// Breached
if (countdownEl && !overrunEl) {
countdownEl.innerHTML = 'SLA BREACHED (+' + fmtHMS(-remaining) + ')';
countdownEl.className = 'lt-text-danger';
} else if (overrunEl) {
overrunEl.textContent = fmtHMS(-remaining);
}
if (progressWrap && !progressWrap.classList.contains('lt-progress--red')) {
progressWrap.className = progressWrap.className.replace('lt-progress--green','').replace('lt-progress--red','') + ' lt-progress--red';
}
if (overrunEl) overrunEl.textContent = fmtHMS(-remaining);
}
}
@@ -475,8 +461,8 @@ include __DIR__ . '/layout_header.php';
<button type="button" class="lt-tab" id="comments-tab-btn"
role="tab" data-tab="comments-panel" aria-selected="false" aria-controls="comments-panel">
Comments
<?php if (!empty($comments)) : ?>
<span class="lt-badge lt-badge-sm"><?= count($comments) ?></span>
<?php if ($totalComments > 0) : ?>
<span class="lt-badge lt-badge-sm"><?= (int)$totalComments ?></span>
<?php endif ?>
</button>
<button type="button" class="lt-tab" id="attachments-tab-btn"
@@ -635,11 +621,14 @@ include __DIR__ . '/layout_header.php';
</div>
</div>
<div class="comment-text<?= $markdownEnabled ? ' lt-markdown' : '' ?>" id="comment-text-<?= $commentId ?>"
<?= $markdownEnabled ? 'data-markdown' : '' ?>>
<?= $markdownEnabled
<?= $markdownEnabled ? 'data-markdown' : '' ?>><?=
// Emit inline (no surrounding whitespace) so a markdown
// comment's text content isn't prefixed with template
// indentation, which would be parsed as a code block.
$markdownEnabled
? htmlspecialchars($comment['comment_text'])
: nl2br(htmlspecialchars($comment['comment_text'])) ?>
</div>
: nl2br(htmlspecialchars($comment['comment_text']))
?></div>
<textarea class="lt-input lt-textarea comment-edit-raw is-hidden"
id="comment-raw-<?= $commentId ?>"
aria-hidden="true"><?= htmlspecialchars($comment['comment_text']) ?></textarea>
+1 -1
View File
@@ -132,7 +132,7 @@ include __DIR__ . '/../../views/layout_header.php';
</p>
<div class="lt-code-block">
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
<pre><code>curl -X POST https://your-instance/api/create_ticket.php \
<pre><code>curl -X POST https://your-instance/create_ticket_api.php \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"My ticket","category":"General","type":"Issue","priority":3}'</code></pre>
+7 -2
View File
@@ -29,9 +29,14 @@ include __DIR__ . '/../../views/layout_header.php';
<label class="lt-label" for="action_type">Action Type</label>
<select name="action_type" id="action_type" class="lt-select lt-select-sm">
<option value="">All Actions</option>
<?php foreach (['create','update','delete','comment','assign','status_change','login','security'] as $a) : ?>
<?php
// Mirrors AuditLogModel::VALID_ACTION_TYPES (the backend whitelist of loggable actions)
$auditActionTypes = ['create','update','delete','view','security_event',
'login','logout','assign','unassign','comment','mention',
'revoke','attachment_upload','attachment_delete','bulk_update'];
foreach ($auditActionTypes as $a) : ?>
<option value="<?= htmlspecialchars($a, ENT_QUOTES, 'UTF-8') ?>" <?= ($filters['action_type'] ?? '') === $a ? 'selected' : '' ?>><?= htmlspecialchars(ucfirst(str_replace('_', ' ', $a)), ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach ?>
<?php endforeach ?>
</select>
</div>
<div class="lt-form-group" style="margin:0">
+13 -1
View File
@@ -43,11 +43,23 @@ include __DIR__ . '/../../views/layout_header.php';
<!-- Summary stats -->
<?php if (!empty($userStats)) : ?>
<?php
// "Active" = users with >=1 tracked action within the selected date range.
// The query LEFT JOINs from all users, so $userStats includes zero-activity users.
$activeUsers = 0;
foreach ($userStats as $_u) {
$_activity = ($_u['tickets_created'] ?? 0) + ($_u['tickets_resolved'] ?? 0)
+ ($_u['comments_added'] ?? 0) + ($_u['tickets_assigned'] ?? 0);
if ($_activity > 0) {
$activeUsers++;
}
}
?>
<div class="lt-stats-grid lt-mb-md">
<div class="lt-stat-card">
<div class="lt-stat-icon lt-text-cyan">[ # ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= count($userStats) ?></div>
<div class="lt-stat-value"><?= (int)$activeUsers ?></div>
<div class="lt-stat-label">Active Users</div>
</div>
</div>
+18 -2
View File
@@ -138,10 +138,13 @@
var themeBtn = document.getElementById('lt-theme-btn');
if (themeBtn) themeBtn.addEventListener('click', function() { lt.theme.toggle(); });
// Command palette — global navigation commands available on all pages
// Command palette — single global instance (overlay DOM above; base.js binds Ctrl/Cmd+K)
var _cpCmds = [
{ id: 'nav-dashboard', group: 'Navigation', icon: '~', label: 'Dashboard', kbd: 'G D', action: function() { window.location.href = '/'; } },
{ id: 'nav-new-ticket', group: 'Navigation', icon: '+', label: 'New Ticket', kbd: 'N', action: function() { window.location.href = '/ticket/create'; } },
{ id: 'filter-mine', group: 'Filter', icon: '◈', label: 'My Open Tickets', action: function() { window.location.href = '/?assigned_to=me&status=Open,In+Progress,Pending'; } },
{ id: 'filter-unassigned', group: 'Filter', icon: '◌', label: 'Unassigned Tickets', action: function() { window.location.href = '/?assigned_to=unassigned'; } },
{ id: 'filter-critical', group: 'Filter', icon: '!', label: 'P1 Critical Tickets', action: function() { window.location.href = '/?priority=1'; } },
{ id: 'help-shortcuts', group: 'Help', icon: '?', label: 'Keyboard Shortcuts', kbd: '?', action: function() { lt.modal.open('lt-keys-help'); } },
{ id: 'help-theme', group: 'Help', icon: '*', label: 'Toggle Theme', action: function() { lt.theme.toggle(); } },
];
@@ -156,7 +159,20 @@
{ id: 'admin-api-keys', group: 'Admin', icon: 'K', label: 'API Keys', action: function() { window.location.href = '/admin/api-keys'; } },
]);
<?php endif ?>
// Recently viewed tickets from localStorage
try {
var _recent = JSON.parse(localStorage.getItem('lt_recent_tickets') || '[]');
_recent.slice(0, 5).forEach(function(id) {
_cpCmds.push({ id: 'recent-' + id, group: 'Recent', icon: '◷', label: 'Ticket #' + id, tags: ['ticket'], action: function(tid) { return function() { window.location.href = '/ticket/' + tid; }; }(id) });
});
} catch (_e) { /* ignore malformed localStorage */ }
lt.cmdPalette.init(_cpCmds);
// Bind the header ⌘K trigger button (no inline onclick — CSP blocks inline handlers)
var _cmdTrigger = document.getElementById('lt-cmd-trigger');
if (_cmdTrigger) {
_cmdTrigger.addEventListener('click', function() { lt.cmdPalette.open(); });
}
}
// Patch lt.api mutating methods to auto-rotate CSRF token when server returns a new one
@@ -194,7 +210,7 @@
return Math.floor(diff / 86400) + 'd ago';
}
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
function renderNotifications(data) {
lt.notif.set(bell, data.unread_count || 0);
+3 -56
View File
@@ -196,7 +196,9 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Loading&hellip;</div>
</div>
<div class="lt-notif-panel-footer">
<?php if ($_lt_isAdmin) : ?>
<a href="/admin/audit-log" class="lt-btn lt-btn-ghost lt-btn-sm lt-w-full lt-text-center">View activity log</a>
<?php endif; ?>
</div>
</div>
</div>
@@ -205,7 +207,6 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
class="lt-btn lt-btn-ghost lt-btn-sm"
title="Command palette (Ctrl+K)"
aria-label="Open command palette"
onclick="if(window.lt&&lt.cmdPalette)lt.cmdPalette.open()"
style="font-size:0.65rem;opacity:0.65;letter-spacing:0.03em;padding:0.2rem 0.45rem">&#x2315;&nbsp;K</button>
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
aria-label="Switch to light mode" title="Switch to light mode">&#x2600;</button>
@@ -213,60 +214,6 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
</header><!-- /.lt-header -->
<!-- ── COMMAND PALETTE OVERLAY (Ctrl+K / ⌘K) ──────────────────── -->
<div id="lt-cmd-overlay" class="lt-cmd-overlay" role="dialog" aria-modal="true" aria-label="Command palette" aria-hidden="true">
<div id="lt-cmd-palette" class="lt-cmd-palette" role="combobox" aria-expanded="true" aria-haspopup="listbox">
<div class="lt-cmd-input-wrap">
<span aria-hidden="true" style="opacity:0.45;margin-right:0.4rem;font-size:0.9em">&#x2315;</span>
<input class="lt-cmd-input" type="text" placeholder="Type a command or search&hellip;"
autocomplete="off" spellcheck="false" aria-label="Command search" aria-autocomplete="list"
aria-controls="lt-cmd-results-list">
<kbd style="font-size:0.6rem;opacity:0.4;white-space:nowrap">ESC</kbd>
</div>
<div class="lt-cmd-results" id="lt-cmd-results-list" role="listbox"></div>
</div>
</div>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
(function() {
var isAdmin = <?= json_encode($_lt_isAdmin) ?>;
document.addEventListener('DOMContentLoaded', function() {
var commands = [
{ id: 'nav-dashboard', label: 'Dashboard', icon: '⌂', group: 'Navigate', action: function(){ location.href = '/'; } },
{ id: 'nav-new-ticket', label: 'New Ticket', icon: '+', group: 'Navigate', kbd: 'N', action: function(){ location.href = '/create'; } },
{ id: 'filter-mine', label: 'My Open Tickets', icon: '◈', group: 'Filter', action: function(){ location.href = '/?assigned_to=me&status=Open,In+Progress,Pending'; } },
{ id: 'filter-unassigned', label: 'Unassigned Tickets', icon: '◌', group: 'Filter', action: function(){ location.href = '/?assigned_to=unassigned'; } },
{ id: 'filter-critical', label: 'P1 Critical Tickets', icon: '!', group: 'Filter', action: function(){ location.href = '/?priority=1'; } },
];
if (isAdmin) {
[
{ id: 'admin-templates', label: 'Admin: Templates', icon: '▤', href: '/admin/templates' },
{ id: 'admin-workflow', label: 'Admin: Workflow', icon: '⇌', href: '/admin/workflow' },
{ id: 'admin-audit', label: 'Admin: Audit Log', icon: '📋', href: '/admin/audit-log' },
{ id: 'admin-api-keys', label: 'Admin: API Keys', icon: '🔑', href: '/admin/api-keys' },
{ id: 'admin-users', label: 'Admin: User Activity', icon: '👤', href: '/admin/user-activity' },
{ id: 'admin-recurring', label: 'Admin: Recurring', icon: '↻', href: '/admin/recurring-tickets' },
{ id: 'admin-fields', label: 'Admin: Custom Fields', icon: '⊞', href: '/admin/custom-fields' },
].forEach(function(c) {
commands.push({ id: c.id, label: c.label, icon: c.icon, group: 'Admin', action: function(href){ return function(){ location.href = href; }; }(c.href) });
});
}
// Inject recent ticket IDs from localStorage
try {
var recent = JSON.parse(localStorage.getItem('lt_recent_tickets') || '[]');
recent.slice(0, 5).forEach(function(id) {
commands.push({ id: 'recent-' + id, label: 'Ticket #' + id, icon: '◷', group: 'Recent', tags: ['ticket'], action: function(tid){ return function(){ location.href = '/ticket/' + tid; }; }(id) });
});
} catch(_) {}
if (window.lt && lt.cmdPalette) lt.cmdPalette.init(commands);
});
// Keyboard shortcut: Ctrl+K / Cmd+K
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
if (window.lt && lt.cmdPalette) lt.cmdPalette.open();
}
});
})();
</script>
<!-- Command palette overlay + init live in layout_footer.php (single instance) -->
<main class="lt-main lt-container" id="main-content" style="padding-top: calc(var(--header-height, 56px) + var(--space-lg, 1.5rem))">