Compare commits

...
Author SHA1 Message Date
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&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
34 changed files with 707 additions and 210 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
+19 -2
View File
@@ -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@v3
- 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
+9 -2
View File
@@ -19,7 +19,14 @@ jobs:
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
+4 -2
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
+17 -5
View File
@@ -50,12 +50,24 @@ $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();
} 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']);
+33 -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,32 @@ $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();
// 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 +197,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 +206,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, '');
+7 -1
View File
@@ -103,7 +103,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;
+16 -5
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>');
@@ -142,7 +153,7 @@ function parseMarkdown(markdown) {
// 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
@@ -154,9 +165,9 @@ function parseMarkdown(markdown) {
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">' +
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)
],
];
+49 -20
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];
@@ -184,6 +201,11 @@ function generateTicketHash($data)
)),
];
// 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 +231,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 +266,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 +304,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 +317,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 +420,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);
+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)) {
+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
*
+23 -11
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;
}
/**
+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 = [];
+15 -1
View File
@@ -94,6 +94,10 @@ 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);
@@ -200,7 +204,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 +253,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();
+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
*/
+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) {