Compare commits

...
Author SHA1 Message Date
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
jaredandClaude Sonnet 4.6 3a4a13db7b Fix semgrep security findings to pass CI security scan
Lint / PHP (phpcs PSR-12) (push) Successful in 28s
Lint / JS (eslint) (push) Successful in 14s
Security / PHP Security (semgrep) (push) Failing after 1m27s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
- index.php: replace SQL string interpolation with concatenation + explicit
  (int) casts for LIMIT/OFFSET; add nosemgrep for tainted-sql false positive
  (WHERE clause built from hardcoded fragments with bound params only)
- api/upload_attachment.php: add realpath() path-traversal guard after mkdir
- api/user_avatar.php: make (int) cast explicit at cache-path construction;
  add nosemgrep for tainted-filename false positive (integer-only input)
- assets/js/ticket.js: add nosemgrep for insertAdjacentHTML — all dynamic
  content already escaped via lt.escHtml() before insertion
- .gitea/workflows/security.yml: exclude echoed-request rule globally —
  all echo in API context is json_encode() output, not HTML; htmlentities()
  fix semgrep suggests would corrupt JSON responses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:42:47 -04:00
jaredandClaude Sonnet 4.6 6b2d8e4d03 Fix remaining spam issues and phpcs merge conflict marker
Lint / PHP (phpcs PSR-12) (push) Successful in 31s
Lint / JS (eslint) (push) Successful in 13s
Security / PHP Security (semgrep) (push) Failing after 1m41s
Lint / Deploy (push) Successful in 4s
Lint / Notify on failure (push) Has been skipped
Spam fixes:
- Add ZFS pool category to hash with subtypes (pool_state, pool_usage,
  pool_errors) so DEGRADED and usage-high on same pool get separate tickets
- Strip volatile percentages from LXC/ZFS usage titles ("usage high: 80.1%"
  → "usage high") and OSD counts from BlueStore slow-ops titles
  ("2 OSD(s) experiencing" → "OSD(s) experiencing") in hwmonDaemon.py

phpcs fix:
- Remove leftover merge conflict marker (<<<<<<< HEAD / >>>>>>>)
  in create_ticket_api.php which caused phpcs to fail on bitshift
  operator spacing

DB cleanup:
- Deleted 107 spam comments and 107 audit entries from tickets
  357934698 (ZFS pool), 673679581 (BlueStore), 925498317 (LXC storage)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:28:59 -04:00
jaredandClaude Sonnet 4.6 7fb60a365e Suppress title-only update comments to stop hourly comment spam
Lint / PHP (phpcs PSR-12) (push) Failing after 45s
Lint / JS (eslint) (push) Successful in 19s
Security / PHP Security (semgrep) (push) Failing after 1m37s
Lint / Deploy (push) Has been skipped
Lint / Notify on failure (push) Successful in 2s
Comments on worsening condition now only fire on priority escalation.
Title and description updates are silent — title changes (e.g. rising
Power_On_Hours counters) were generating a comment on every hourly run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:16:41 -04:00
jaredandClaude Sonnet 4.6 fb3b607bd1 Resolve merge conflict in create_ticket_api.php OSD regex
Lint / PHP (phpcs PSR-12) (push) Failing after 33s
Lint / JS (eslint) (push) Successful in 15s
Security / PHP Security (semgrep) (push) Failing after 2m20s
Lint / Deploy (push) Has been skipped
Lint / Notify on failure (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:10:14 -04:00
jaredandClaude Sonnet 4.6 dad7c24bff Fix hwmonDaemon hash collisions and automated comment formatting
- source_type (auto vs manual) added to dedup hash so automated
  tickets never collide with manually created ones
- OSD-specific subtype (osd_down_N) so each OSD gets its own ticket
- Description refreshed on every automated update (current sensor data)
- Comments on worsening condition only fire on meaningful changes
- ASCII art descriptions wrapped in fenced code blocks in comments
- Reopen comment also uses fenced code block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:09:12 -04:00
40 changed files with 953 additions and 314 deletions
+8
View File
@@ -24,6 +24,14 @@ 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).
# STRONGLY RECOMMENDED in production: Authelia forward-auth (Remote-User /
# Remote-Groups) and forwarded client IPs are only trusted when REMOTE_ADDR is
# in this list. Leaving it empty disables that protection (relies solely on
# network topology) and lets anything reaching PHP directly spoof admin login.
# Exact IP match only (no CIDR). Example: TRUSTED_PROXIES=10.10.10.27
TRUSTED_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
+10 -3
View File
@@ -13,13 +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 .
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 \
.
+5 -3
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.
@@ -569,12 +570,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
+5 -3
View File
@@ -46,8 +46,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
@@ -68,7 +70,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
}
fputcsv($output, [
$log['log_id'],
$log['audit_id'] ?? ($log['log_id'] ?? ''),
$log['created_at'],
$log['display_name'] ?? $log['username'] ?? 'N/A',
$log['action_type'],
+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
+34
View File
@@ -95,6 +95,40 @@ 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);
+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'] ?? '?');
+20 -15
View File
@@ -127,6 +127,25 @@ try {
];
}
// 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));
}
// 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'
];
}
}
// Validate status transition using workflow model
if ($currentTicket['status'] !== $updateData['status']) {
$allowed = $this->workflowModel->isTransitionAllowed(
@@ -160,22 +179,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(
+6 -1
View File
@@ -144,13 +144,18 @@ if (!is_dir($uploadDir)) {
}
}
// Create ticket subdirectory
// Create ticket subdirectory — ticketId is validated as digits-only above
$ticketDir = $uploadDir . '/' . $ticketId;
if (!is_dir($ticketDir)) {
if (!mkdir($ticketDir, 0755, true)) {
ResponseHelper::serverError('Failed to create ticket upload directory');
}
}
// Confirm resolved path stays within the upload root (defence-in-depth)
$resolvedTicketDir = realpath($ticketDir);
if ($resolvedTicketDir === false || strpos($resolvedTicketDir, realpath($uploadDir)) !== 0) {
ResponseHelper::error('Invalid upload path');
}
// Derive extension from validated MIME type (never from user-supplied filename)
// This prevents executable extension attacks (e.g. evil.php disguised as text/plain)
+18 -7
View File
@@ -56,8 +56,11 @@ if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0755, true);
}
$cacheFile = $cacheDir . '/user_' . $userId . '.jpg';
$cacheTtl = (int)($cfg['AVATAR_CACHE_TTL'] ?? 3600);
// Build cache paths from the validated integer $userId — no user-supplied strings used
$safeUserId = (int)$userId; // nosemgrep: php.lang.security.injection.tainted-filename.tainted-filename
$cacheFile = $cacheDir . '/user_' . $safeUserId . '.jpg';
$noAvatarSentinel = $cacheDir . '/user_' . $safeUserId . '.none';
$cacheTtl = (int)($cfg['AVATAR_CACHE_TTL'] ?? 3600);
// Serve from cache if fresh
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
@@ -69,7 +72,6 @@ if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
}
// A sentinel empty file means "no avatar" — don't re-query LDAP until TTL expires
$noAvatarSentinel = $cacheDir . '/user_' . $userId . '.none';
if (file_exists($noAvatarSentinel) && (time() - filemtime($noAvatarSentinel)) < $cacheTtl) {
http_response_code(404);
exit;
@@ -108,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");
@@ -135,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, '');
+18 -1
View File
@@ -78,6 +78,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 +114,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;
+31 -26
View File
@@ -1142,8 +1142,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');
@@ -1219,29 +1221,30 @@ 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) + ')';
};
// POST status update via the shared wrapper (adds CSRF + JSON, throws on non-2xx)
lt.api.post('/api/update_ticket.php', { ticket_id: String(ticketId), status: 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 () {
lt.toast.error('Status update failed — reverting');
revert();
});
}
Object.keys(columns).forEach(status => {
@@ -1314,7 +1317,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() || '';
+22 -9
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,10 +32,14 @@ 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>');
@@ -131,18 +142,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 +166,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>';
}
+1 -1
View File
@@ -735,7 +735,7 @@ function renderDependencies(dependencies) {
// Insert blocker alert above the frame if not already there
const panel = document.getElementById('dependencies-panel');
if (panel && !panel.querySelector('#blockerAlert')) {
panel.insertAdjacentHTML('afterbegin', alertHtml);
panel.insertAdjacentHTML('afterbegin', alertHtml); // nosemgrep: typescript.react.security.audit.react-unsanitized-method.react-unsanitized-method
}
}
+10
View File
@@ -60,6 +60,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)
],
];
+60 -24
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;
}
@@ -129,6 +131,21 @@ function generateTicketHash($data)
if (stripos($title, 'SMART issues') !== false) {
$issueCategory = 'smart';
} elseif (stripos($title, 'ZFS pool') !== false) {
$issueCategory = 'zfs';
// Extract pool name so each pool gets its own ticket
if (preg_match("/ZFS pool '([^']+)'/i", $title, $poolMatch)) {
$poolName = strtolower(preg_replace('/[^a-z0-9_]/i', '_', $poolMatch[1]));
if (stripos($title, 'state:') !== false || preg_match('/DEGRADED|FAULTED|UNAVAIL|OFFLINE/i', $title)) {
$issueSubtype = 'pool_state_' . $poolName;
} elseif (stripos($title, 'usage') !== false) {
$issueSubtype = 'pool_usage_' . $poolName;
} elseif (stripos($title, 'errors') !== false) {
$issueSubtype = 'pool_errors_' . $poolName;
} else {
$issueSubtype = 'pool_' . $poolName;
}
}
} elseif (stripos($title, 'LXC') !== false || stripos($title, 'storage usage') !== false) {
$issueCategory = 'storage';
// Include the LXC container ID so each container gets its own ticket
@@ -158,7 +175,7 @@ function generateTicketHash($data)
$issueSubtype = 'clock_skew';
} elseif (stripos($title, 'cluster usage') !== false) {
$issueSubtype = 'usage';
} elseif (stripos($title, 'OSD down') !== false || preg_match('/OSD\s+osd\.\d+\s+is\s+DOWN/i', $title)) {
} elseif (stripos($title, 'OSD down') !== false || preg_match('/osd\.\d+\s+is\s+DOWN/i', $title)) {
// Include the specific OSD ID so each individual OSD gets its own ticket
if (preg_match('/osd\.(\d+)/i', $title, $osdMatch)) {
$issueSubtype = 'osd_down_' . $osdMatch[1];
@@ -178,12 +195,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;
@@ -209,6 +238,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);
@@ -228,8 +273,7 @@ if ($existing) {
if ($existingStatus !== 'Closed') {
// Ticket is still active — update title, escalate priority, and refresh
// the description with the latest sensor data if the new report is more severe
// (lower priority number = higher severity).
// description with latest sensor data.
$changes = [];
$updateSql = "UPDATE tickets SET updated_at = NOW(), updated_by = ?";
$bindTypes = "i";
@@ -267,20 +311,10 @@ if ($existing) {
$updStmt->execute();
$updStmt->close();
// Only add a comment when something meaningful changed (not just a description refresh)
$meaningfulChanges = array_diff_key($changes, ['description_refreshed' => true]);
if (!empty($meaningfulChanges)) {
$changeLines = [];
if (isset($changes['title'])) {
$changeLines[] = "- **Title updated** to reflect current issue";
}
if (isset($changes['priority'])) {
$changeLines[] = "- **Priority escalated** from P{$changes['priority']['from']} to P{$changes['priority']['to']}";
}
// Wrap description in a fenced code block so ASCII art / box-drawing
// characters render correctly instead of collapsing into a paragraph blob
$commentText = "**hwmonDaemon reported a worsened condition — ticket updated automatically.**\n\n" .
implode("\n", $changeLines) . "\n\nLatest report:\n\n```\n" . $description . "\n```";
// Only post a comment on priority escalation — title and description updates
// are silent (title changes like rising counters would spam a comment every run)
if (isset($changes['priority'])) {
$commentText = "**hwmonDaemon escalated this ticket from P{$changes['priority']['from']} to P{$changes['priority']['to']}.**\n\n```\n" . $description . "\n```";
$commentStmt = $conn->prepare(
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
);
@@ -290,7 +324,7 @@ if ($existing) {
}
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
$changes,
array_diff_key($changes, ['description_refreshed' => true]),
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
));
@@ -393,7 +427,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;
}
+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') {
+23 -24
View File
@@ -5,19 +5,18 @@
* 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 'models/RecurringTicketModel.php';
require_once 'models/TicketModel.php';
require_once 'models/AuditLogModel.php';
@@ -31,17 +30,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 +50,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 +75,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,9 +92,6 @@ try {
['source' => 'recurring', 'recurring_id' => $recurring['recurring_id']]
);
// Update the recurring ticket's next run time
$recurringModel->updateAfterRun($recurring['recurring_id']);
$created++;
} else {
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
@@ -106,7 +105,7 @@ try {
logMessage("Completed: Created $created tickets, $errors errors");
$conn->close();
Database::close();
} catch (Exception $e) {
logMessage("FATAL ERROR: " . $e->getMessage());
exit(1);
+13 -6
View File
@@ -125,16 +125,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]);
}
}
+31 -16
View File
@@ -164,23 +164,38 @@ 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);
}
$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;
+7 -3
View File
@@ -278,7 +278,10 @@ switch (true) {
$where = !empty($whereConditions) ? 'WHERE ' . implode(' AND ', $whereConditions) : '';
$countSql = "SELECT COUNT(*) as total FROM audit_log al $where";
// $where contains only hardcoded SQL fragments with ? placeholders — user values
// are bound via bind_param below, never interpolated. LIMIT/OFFSET are explicit ints.
// nosemgrep: php.lang.security.injection.tainted-sql-string.tainted-sql-string
$countSql = "SELECT COUNT(*) as total FROM audit_log al " . $where;
if (!empty($params)) {
$stmt = $conn->prepare($countSql);
$stmt->bind_param($types, ...$params);
@@ -290,12 +293,13 @@ switch (true) {
$totalLogs = $countResult->fetch_assoc()['total'];
$totalPages = ceil($totalLogs / $perPage);
// nosemgrep: php.lang.security.injection.tainted-sql-string.tainted-sql-string
$sql = "SELECT al.*, u.display_name, u.username
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
$where
" . $where . "
ORDER BY al.created_at DESC
LIMIT $perPage OFFSET $offset";
LIMIT " . (int)$perPage . " OFFSET " . (int)$offset;
if (!empty($params)) {
$stmt = $conn->prepare($sql);
+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;
+10 -6
View File
@@ -10,6 +10,9 @@ 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;
@@ -36,12 +39,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);
}
/**
@@ -534,7 +537,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 +564,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 = [];
+20 -1
View File
@@ -94,12 +94,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 +209,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 +258,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();
+34 -20
View File
@@ -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 = 'i' . 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 = [];
+25 -7
View File
@@ -190,14 +190,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 +247,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;
}
+38
View File
@@ -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),
+41 -6
View File
@@ -208,6 +208,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 +233,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,
@@ -740,9 +763,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 = [];
@@ -804,13 +831,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();
+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);
+4 -4
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];
}
@@ -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*/, '') : '';
+42 -56
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);
}
}
+7 -1
View File
@@ -205,7 +205,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>
@@ -258,6 +257,13 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
});
} catch(_) {}
if (window.lt && lt.cmdPalette) lt.cmdPalette.init(commands);
// Bind the header ⌘K trigger here (no inline onclick — CSP blocks inline handlers)
var cmdTrigger = document.getElementById('lt-cmd-trigger');
if (cmdTrigger) {
cmdTrigger.addEventListener('click', function() {
if (window.lt && lt.cmdPalette) lt.cmdPalette.open();
});
}
});
// Keyboard shortcut: Ctrl+K / Cmd+K
document.addEventListener('keydown', function(e) {