285 Commits

Author SHA1 Message Date
jared 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
jared 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
jared 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
jared 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
jared 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
jared 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
jared 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
jared 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
jared 9e9d8a33e3 Fix hwmonDaemon hash collisions and automated description rendering
Lint / PHP (phpcs PSR-12) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 13s
Security / PHP Security (semgrep) (push) Failing after 1m59s
Lint / Deploy (push) Successful in 5s
Lint / Notify on failure (push) Has been skipped
- Include source_type (auto vs manual) in dedup hash so automated
  tickets never collide with manually created ones. This was causing
  hwmonDaemon to hijack manual task tickets that shared the same
  cluster/category/environment tags.

- Include specific OSD ID in hash subtype (osd_down_N) so each OSD
  failure gets its own ticket instead of all colliding to osd_down.

- Wrap hwmonDaemon report descriptions in fenced code blocks in
  comments so ASCII art box-drawing renders correctly instead of
  collapsing into a paragraph blob.

- Refresh ticket description on every automated update so the ticket
  body shows current sensor data, not stale values from first report.

- Only post a worsening-condition comment when title or priority
  actually changed (not just a description refresh).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:06:13 -04:00
jared dfae1d4648 ci: add notify-failure, deploy tagging, and PHP security scanning
Lint / PHP (phpcs PSR-12) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 12s
Security / PHP Security (semgrep) (push) Failing after 55s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
- lint.yml: add notify-failure Matrix alert job; add Tag deployed commit
  step (main branch only) with deploy-YYYY.MM.DD-N tagging via Gitea API;
  add permissions: contents: write to deploy job
- security.yml: new workflow running semgrep with p/php and p/owasp-top-ten
  configs on push, PR, and weekly schedule

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:25:18 -04:00
jared ac82300675 Add CI badge and CI/CD section to README
Lint / PHP (phpcs PSR-12) (push) Successful in 27s
Lint / JS (eslint) (push) Successful in 16m21s
Lint / Deploy (push) Successful in 4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:54:00 -04:00
jared 31510cfe0f ci: gate deploy behind lint — Actions triggers webhook after lint passes
Lint / PHP (phpcs PSR-12) (push) Successful in 30s
Lint / JS (eslint) (push) Successful in 13s
Lint / Deploy (push) Successful in 3s
Adds a deploy job that runs only when both php-lint and js-lint succeed.
Calls the CT132 webhook directly with HMAC-SHA256 signature from the
WEBHOOK_SECRET repo secret. Disabled the direct push webhooks that
previously deployed on every push regardless of lint status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:42:34 -04:00
jared 5fce716489 style: fix final 2 phpcs violations; exclude line-length rule
Lint / PHP (phpcs PSR-12) (push) Successful in 36s
Lint / JS (eslint) (push) Successful in 16s
2026-04-13 21:08:19 -04:00
jared c90bdc8ac8 style: auto-fix 1340 phpcs PSR-12 violations via phpcbf; exclude MissingNamespace and SideEffects
Lint / PHP (phpcs PSR-12) (push) Failing after 29s
Lint / JS (eslint) (push) Successful in 12s
2026-04-13 20:56:10 -04:00
jared b6df647921 ci: add php-xml for phpcs xmlwriter/SimpleXML deps
Lint / PHP (phpcs PSR-12) (push) Failing after 28s
Lint / JS (eslint) (push) Successful in 12s
2026-04-13 20:51:17 -04:00
jared e3a115fd02 ci: install php via apt, relax eslint rules for existing codebase
Lint / PHP (phpcs PSR-12) (push) Failing after 26s
Lint / JS (eslint) (push) Successful in 12s
2026-04-13 20:47:26 -04:00
jared 46285b8abc ci: use php:8.2-cli container for phpcs job
Lint / PHP (phpcs PSR-12) (push) Failing after 19s
Lint / JS (eslint) (push) Failing after 13s
2026-04-13 20:41:03 -04:00
jared d38cc1bfbe ci: add phpcs and eslint linting workflow
Lint / PHP (phpcs PSR-12) (push) Failing after 7s
Lint / JS (eslint) (push) Failing after 16s
2026-04-13 20:34:21 -04:00
jared 56007f7479 Fix admin dropdown dismissing when cursor moves into menu
The menu was positioned top:calc(100%+4px), leaving a 4px dead zone
between the trigger and the menu that interrupted the :hover chain.
Changed to top:100% with padding-top:6px + margin-top:-2px so the
menu's hover area is contiguous with the trigger — no more needing
to mouse over quickly to keep the dropdown open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 00:41:29 -04:00
jared 7dba849c12 Fix footer appearing mid-page when content is minimal
body lacked display:flex + flex-direction:column, so the existing
flex:1 on .lt-main had no effect. Error pages (404, 403) and any
page with little content showed the footer immediately after content
rather than pinned to the viewport bottom.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 00:37:28 -04:00
jared 3e9f5e82db Use styled 404 page for missing/inaccessible tickets
Replaces the bare "Ticket not found" text response with the shared
views/error_404.php partial so users see the full TDS-styled error page.
Also collapsed the two identical 404 branches into one check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 00:34:15 -04:00
jared f42ee8070f Fix COPY button on API Keys page
lt.copy() does not exist — the correct API is lt.clipboard.copy().
Also added ok-check since clipboardCopy() returns a boolean promise,
not a rejection on failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 00:03:37 -04:00
jared 3b0b7621e0 Block web access to generate_api_key.php
Added php_sapi_name() CLI guard matching the pattern used in migrate.php
and cleanup_ratelimit.php. Without this, the script was web-accessible
and could generate an API key without authentication if no keys existed yet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 21:44:35 -04:00
jared e3ebc766e5 Fix open redirect in legacy ticket.php URL handler
The /ticket.php?id=VALUE redirect did not validate the id parameter,
allowing path traversal (e.g. ?id=../admin) or other unexpected values
in the Location header. Added ctype_digit validation so only positive
numeric IDs are redirected to /ticket/N; anything else falls back to /.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 21:08:55 -04:00
jared 2d6b2b8058 Fix watcher self-notification, unescaped output in views
- NotificationHelper::notifyWatchers: excludeUserId parameter was
  accepted but never used; actors were notified of their own actions.
  Fix: add AND tw.user_id != ? clause to watcher query when exclusion
  is requested.

- TicketView.php: formatAction() default case returned raw
  $event['action_type'] unescaped into HTML context. Fix: wrap with
  htmlspecialchars().

- Admin views: field_id, recurring_id, template_id, transition_id
  in data-id attributes were uncast; field_type was unescaped in
  CustomFieldsView; from/to_status slugs derived from DB values were
  used directly in class attributes in WorkflowDesignerView.
  Fix: (int) cast for IDs, htmlspecialchars for field_type,
  preg_replace to sanitize DB-derived CSS class slugs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 20:41:09 -04:00
jared 3c7b3475e4 Fix command palette 'Unassigned Tickets' filter using wrong parameter
The filter action used ?assigned_to=none but DashboardController only
recognises 'unassigned' as the sentinel value for that filter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:30:31 -04:00
jared 55c2d5c596 Fix visibility bypass in export and insecure cookie in preferences
api/export_tickets.php: getAllTickets() was called without $currentUser,
so visibility filtering was skipped — any authenticated user could export
all tickets including confidential/internal ones.

api/user_preferences.php: the single-preference setcookie() call was
missing httponly/secure flags (batch path had them correctly). Also cast
preference values to string before passing to setPreference(string).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:29:09 -04:00
jared 0f71ef9935 Fix ?priority=N filter from command palette not being applied
The command palette 'P1 Critical Tickets' link uses ?priority=1, but
getAllTickets only accepted priority_min/priority_max. Resolve the single
?priority=N param to both priority_min and priority_max so it acts as
an exact priority match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:20:48 -04:00
jared e2eabad413 Fix assigned_to=me filter not working from command palette
The command palette 'My Open Tickets' action navigates to
?assigned_to=me, but DashboardController only handled numeric IDs
and 'unassigned', silently ignoring 'me'. Resolve 'me' to the
current user's ID. Also update the active filter chip to display
'Me' instead of 'User #me'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:19:11 -04:00
jared 9a8940b9d0 Fix createTicket duplicate-key retry handler for PHP 8.2
PHP 8.2 strict mysqli mode throws mysqli_sql_exception on duplicate key
rather than returning false from execute(). Replace the old if/else errno
check with try/catch on mysqli_sql_exception, re-throw non-1062 errors,
and use random_int range 100000000-999999999 (no leading zeros) for the
retry ID.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:17:34 -04:00
jared f7321863e6 Merge remote-tracking branch 'origin/main' into development 2026-04-11 14:11:16 -04:00
jared d21691a548 Fix deleteTicket crash when ticket_custom_fields table doesn't exist
PHP 8.2 raises mysqli_sql_exception on prepare() for non-existent tables
rather than returning false. Wrap each child-table delete in try/catch and
silently skip tables that don't exist in all deployments, re-throwing for
unexpected errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:09:34 -04:00
jared b385e177ec Merge branch 'development' 2026-04-11 13:45:47 -04:00
jared 60f23051a9 Fix notifications not detecting comment events
AuditLogModel::logCommentCreate logs comments with action_type='comment'
not 'create'. The notification query was filtering on action_type='create'
only, so comment events on watched/owned tickets were never surfaced.

Widen the filter to IN ('comment', 'create') to match the actual logged
values while staying compatible with any legacy entries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:45:04 -04:00
jared f9faca55bb Merge branch 'development' 2026-04-11 13:42:49 -04:00
jared 1b75ad14fb Fix kanban drag-and-drop to send ticket_id as string
parseInt(ticketId, 10) was stripping leading zeros before sending
to update_ticket.php. Switch to String(ticketId) for consistency
with all other ticket ID handling in the JS codebase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:42:30 -04:00
jared 1a85d20b8e Merge branch 'development' 2026-04-11 13:41:00 -04:00
jared c442e2d47f Fix AttachmentModel ticket_id binding to preserve leading zeros
All ticket_id parameters were bound as integer ("i"), which stripped
leading zeros before insertion into ticket_attachments.ticket_id
(VARCHAR 9). This caused a mismatch: upload_attachment.php creates
the directory using the full string (e.g. /uploads/000123456/) but
the DB stored the integer form ("123456"), so download and delete
would look in the wrong path.

Changed getAttachments, addAttachment, getTotalSizeForTicket, and
getAttachmentCount to use string binding ("s") so the canonical
zero-padded ticket ID is stored and read back consistently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:40:15 -04:00
jared a62123236d Merge branch 'development' 2026-04-11 13:31:34 -04:00
jared 47b70b0ee8 Fix ticket ID handling in assign and delete_attachment APIs
assign_ticket.php: preserve string ticket ID (ctype_digit validation)
  instead of (int) cast for consistent audit logging and URL generation.

delete_attachment.php: use string ticket_id from DB for the upload
  directory path — (int) cast was stripping leading zeros, causing
  the wrong path (/uploads/123456/) instead of /uploads/000123456/.
  Also pass raw string to getTicketById() to let TicketModel handle
  type coercion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:31:10 -04:00
jared b841037130 Merge branch 'development' 2026-04-11 13:22:20 -04:00
jared 6b89a14a47 Fix ticket ID generation in create_ticket_api.php to avoid leading zeros
Use random_int(100000000-999999999) so IDs are always 9 digits without
a leading zero, matching the behaviour of TicketModel::createTicket().
The old sprintf('%09d', mt_rand(1, ...)) could produce IDs like
000123456 which broke PHP array key lookups elsewhere.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:06:08 -04:00
jared 63092ac070 Fix leading-zero ticket ID in clone_ticket.php
- Preserve source ticket ID as string (ctype_digit validation)
  instead of int-casting with (int)
- Use $sourceTicket['ticket_id'] (canonical DB form) when creating
  the relates_to dependency and audit log entry; avoids storing
  "123456" instead of "000123456" which breaks string-based
  depends_on_id lookups in DependencyModel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:03:16 -04:00
jared d0c889a594 Fix leading-zero ticket ID in clone_ticket.php
- Preserve source ticket ID as string (ctype_digit validation)
  instead of int-casting with (int)
- Use $sourceTicket['ticket_id'] (canonical DB form) when creating
  the relates_to dependency and audit log entry; avoids storing
  "123456" instead of "000123456" which breaks string-based
  depends_on_id lookups in DependencyModel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:02:49 -04:00
jared f93cebe2d9 Implement bulk_delete operation; validate operation types
- TicketModel: add deleteTicket() that removes all child records
  (comments, watchers, dependencies, attachments, custom fields)
  then deletes the ticket and cleans up physical attachment files
- BulkOperationsModel: add bulk_delete case to processBulkOperation()
  so the "Bulk Delete" UI button actually works instead of silently
  failing with N failures
- bulk_operation.php: validate operation_type against whitelist to
  reject unknown operations early with a proper error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 12:54:35 -04:00
jared ab0edd1325 Implement bulk_delete operation; validate operation types
- TicketModel: add deleteTicket() that removes all child records
  (comments, watchers, dependencies, attachments, custom fields)
  then deletes the ticket and cleans up physical attachment files
- BulkOperationsModel: add bulk_delete case to processBulkOperation()
  so the "Bulk Delete" UI button actually works instead of silently
  failing with N failures
- bulk_operation.php: validate operation_type against whitelist to
  reject unknown operations early with a proper error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 12:53:53 -04:00
jared a3fbad19c9 Fix leading-zero ticket ID handling across API and UI
- dashboard.js: use String(cb.value) instead of parseInt() in
  getSelectedTicketIds() so zero-padded IDs like 000123456 are
  preserved when sent to bulk_operation.php
- DashboardView.php: remove (int) cast on data-ticket-id attribute
  for quick-status button; was stripping leading zeros
- TicketView.php: remove (int) cast on export URL ticket_id param
- update_ticket.php: preserve ticket_id as string via trim((string)...)
- add_comment.php: preserve ticket_id as string; validate with
  ctype_digit instead of (int) cast so comments are stored with the
  canonical zero-padded ID matching the tickets table
- export_tickets.php: validate singleId as string to avoid stripping
  leading zeros in the export endpoint
- notifications.php: preserve ticket_id strings in URLs and ticket
  ownership checks; index myTicketIds by both int and string forms
  for robust lookup regardless of how audit_log stored the ID
- TicketController.php: fix inline dependency insert — column was
  wrong (depends_on_ticket_id → depends_on_id) and bind types were
  wrong ("iii" → "ssi"); feature was silently broken

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 12:43:29 -04:00
jared d295d64f85 Fix leading-zero ticket ID handling across API and UI
- dashboard.js: use String(cb.value) instead of parseInt() in
  getSelectedTicketIds() so zero-padded IDs like 000123456 are
  preserved when sent to bulk_operation.php
- DashboardView.php: remove (int) cast on data-ticket-id attribute
  for quick-status button; was stripping leading zeros
- TicketView.php: remove (int) cast on export URL ticket_id param
- update_ticket.php: preserve ticket_id as string via trim((string)...)
- add_comment.php: preserve ticket_id as string; validate with
  ctype_digit instead of (int) cast so comments are stored with the
  canonical zero-padded ID matching the tickets table
- export_tickets.php: validate singleId as string to avoid stripping
  leading zeros in the export endpoint
- notifications.php: preserve ticket_id strings in URLs and ticket
  ownership checks; index myTicketIds by both int and string forms
  for robust lookup regardless of how audit_log stored the ID
- TicketController.php: fix inline dependency insert — column was
  wrong (depends_on_ticket_id → depends_on_id) and bind types were
  wrong ("iii" → "ssi"); feature was silently broken

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 12:43:18 -04:00
jared d6603d07f2 Fix bulk operation dropping tickets with leading-zero IDs, add query null-check
bulk_operation.php: ticket ID validation was converting IDs to int then back
to string, so '000123456' became '123456' which never matched the DB VARCHAR
key, silently rejecting ~11% of tickets from bulk operations. Now validates
with ctype_digit() to preserve leading zeros.

TicketModel::getTicketsByIds(): changed intval() to strval() and bind type
'i' to 's' so VARCHAR ticket_id columns are queried consistently as strings.

DashboardController::getCategoriesAndTypes(): added null check on query
result before calling fetch_assoc() to prevent TypeError if query fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:29:37 -04:00
jared d443caf059 Fix bulk operation dropping tickets with leading-zero IDs, add query null-check
bulk_operation.php: ticket ID validation was converting IDs to int then back
to string, so '000123456' became '123456' which never matched the DB VARCHAR
key, silently rejecting ~11% of tickets from bulk operations. Now validates
with ctype_digit() to preserve leading zeros.

TicketModel::getTicketsByIds(): changed intval() to strval() and bind type
'i' to 's' so VARCHAR ticket_id columns are queried consistently as strings.

DashboardController::getCategoriesAndTypes(): added null check on query
result before calling fetch_assoc() to prevent TypeError if query fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:29:14 -04:00
jared e9a033d4ef Add markdown cheat sheet modal to ticket comment editor
A '?' button next to the MD/Preview toggles opens a reference modal
covering all supported syntax: inline formatting, headings, lists,
task lists, tables, code blocks, footnotes, emoji shortcodes, and
ticket references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:31:46 -04:00
jared 3a516c5424 Fix markdown preview missing lt-markdown class
The #markdownPreview div lacked the lt-markdown class, so CSS rules for
list-style (ul bullets, ol numbers), mark, del, task items etc. never
applied during live preview while typing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:30:13 -04:00
jared 74d1770cd6 Fix ordered list bullets and implement footnotes
- CSS: global reset `ul, ol { list-style: none }` was killing all bullets
  and numbers. Add list-style: disc/decimal back on .lt-markdown ul/ol.
  Remove duplicate ol rules.
- Footnotes: implement [^label] / [^label]: syntax. Uses placeholder
  approach (like code blocks) so <sup> tags aren't HTML-escaped. Renders
  inline superscript refs + numbered footnote block at bottom with
  back-links.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:28:02 -04:00
jared ddf1d236eb Fix markdown CSS not applying to comments — add lt-markdown class
comment-text divs with data-markdown were never getting the lt-markdown
class, so all scoped CSS (ul/ol/li bullets, mark, del, task items, etc.)
had no effect. Fixed in PHP template, JS comment builder, and
renderMarkdownComments().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:23:32 -04:00
jared ccd53dae00 Fix ordered and unordered list rendering
Replace broken regex-based list wrapping with placeholder approach:
each list item type (OLI/ULI/TDI/TTI) gets a unique tag, then consecutive
runs are wrapped in the correct <ol>/<ul> container. Mixed task + regular
items in the same list work correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:19:26 -04:00
jared cd83464c5d Add extended markdown: task lists, highlight, sub/superscript, heading IDs, emoji
- Task lists: - [x] / - [ ] with checkbox glyphs, done items struck through
- Highlight: ==text== -> <mark>
- Subscript: ~text~ -> <sub> (runs after ~~ strikethrough to avoid conflict)
- Superscript: ^text^ -> <sup>
- Heading IDs: ### Title {#my-id} adds id attribute for anchor links
- Ordered lists: now properly wrapped in <ol>
- Emoji: :name: shortcodes (~100 common emojis)
- CSS for all new elements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:13:38 -04:00
jared 47c631ad4f Add strikethrough support to markdown parser (~~text~~)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:10:38 -04:00
jared 50e6ee749e Fix image rendering in markdown comments
- markdown.js: add renderMarkdownComments() called on DOMContentLoaded to
  process [data-markdown] elements that were never being rendered on page load
- CSP: allow https: in img-src so external images in markdown aren't blocked

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:05:37 -04:00
jared 846417580e Add image rendering to markdown parser
Support ![alt](url) syntax in comments/descriptions. Images are only
rendered for http/https URLs. Style via .md-image (max-width, border,
block display) consistent with existing .lt-markdown img rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:03:25 -04:00
jared 8e8a63fa7d Fix sidebar toggle, ? shortcut, footer hint styling
- Sidebar: replace 32px overflow:hidden collapse with display:none — eliminates pointer-event/layout issues; button label toggles between 'Filters' and 'Show Filters'
- Keyboard shortcut ?: fix keydown handler to omit shift+ prefix for symbol keys (shift state already encoded in e.key), so '?' registration matches correctly
- Footer: add missing CSS for .lt-footer-hint, .lt-footer-key, .lt-footer-sep — resets button defaults so CFG/HELP render identically to link-style hints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 23:52:06 -04:00
jared 424f3f9f95 Fix sidebar toggle button by binding directly instead of delegating
Event delegation for toggle-sidebar was inside the isDashboard guard
so it could silently not register. Bind .lt-sidebar-toggle buttons
directly on DOMContentLoaded — simple and guaranteed to work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 23:22:45 -04:00
jared 8cb7cc0356 Fix sidebar toggle button not responding after collapse
toggle-sidebar action was only in the DashboardView inline script,
not in dashboard.js where toggleSidebar() is defined. Move it into
the dashboard.js event delegation switch so it's guaranteed to fire.
Also fix beta webhook: was using a different secret than production
so Gitea pushes to development never triggered the beta deploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 23:19:27 -04:00
jared 5c1ec6882e Fix sidebar collapse/expand UX
JS was toggling .collapsed on the wrong element (dashboardSidebar div
instead of lt-sidebar aside), and the expand button was permanently
display:none. When collapsed, users had no way to re-expand.

- toggleSidebar now targets lt-sidebar (the aside)
- Toggle button flips ◀ ↔ ▶ to indicate state and serve as the expand button
- Collapsed CSS hides the body and label, centers the ▶ button in the strip
- Remove the dead sidebarExpandBtn element from HTML
- Persist and restore state correctly on page load

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 23:11:47 -04:00
jared 355b173070 Fix Created Today tile showing fewer tickets than stat count
Tile click was omitting status param so controller applied default
Open/Pending/In Progress filter, hiding closed/other-status tickets
created today. Pass show_all=1 instead to match the stat count which
includes all statuses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 23:05:00 -04:00
jared 603ba18067 Fix dashboard stat tiles and add sidebar date filters
- Created Today tile: no longer limits to open statuses (count is all statuses)
- Closed Today tile: filters by closed_at range, not updated_at
- Add closed_from/closed_to support to TicketModel and DashboardController
- Add Created/Updated/Closed date range inputs to sidebar filter panel
- Apply button collects date inputs; Clear All removes them
- removeFilter handles date chip removal (clears both _from and _to)
- Active filter chips shown for date ranges

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 23:03:14 -04:00
jared dd98bfbd49 Fix dashboard sidebar filters not working
JS was querying .filter-group but the HTML uses .lt-filter-group,
so no checkboxes were ever collected and filters had no effect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:40:03 -04:00
jared 55a3d2945c Fix comment avatar, activity log labels, and ticket update permissions
- add_comment.php: include user_id in response for avatar rendering
- ticket.js: add buildCommentElement() helper that matches server-rendered
  comment structure (avatar, edit/delete buttons, textarea); use it in
  addComment() and submitReply() so new comments show the avatar immediately
- AuditLogModel: logCommentCreate uses action_type='comment' not 'create'
- TicketView: formatAction handles entity_type='comment' with action_type='create'
  for existing DB records; prevents "created this ticket" showing for comments
- update_ticket.php: remove owner/assignee restriction so any authenticated
  team member can update ticket status and fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:37:53 -04:00
jared 727c5171ff Fix lt-avatar color modifiers overridden by light-theme rule
html[data-theme="light"] .lt-avatar has specificity 0,2,1 which
beats the color modifier classes (0,1,0), stripping the purple/orange/
green/red tints in light mode. Add per-modifier light-theme overrides
immediately after the generic rule so they win the cascade.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:15:19 -04:00
jared 444dc4bf26 Remove scripts/ directory from repo
One-off migration scripts and deploy helpers do not belong in the
repository. Run them locally or from /tmp as needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 21:47:08 -04:00
jared 09292119e6 Only send Matrix notification on priority escalation, not title updates
Title changes (e.g. rising Power_On_Hours counter) were firing a Matrix
ping every hour. Notifications now only trigger when priority escalates
to a higher severity level.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 21:43:22 -04:00
jared 499060795e Use drive serial for dedup hash; update title/priority on worsened conditions
Two changes to the external ticket API:

1. Serial-based dedup: generateTicketHash() now uses the `serial` field
   from the hwmonDaemon payload as the stable drive identifier instead of
   extracting /dev/sdX from the title. Device path is kept as a fallback
   for payloads without a serial field (backwards compatible).
   Hash key renamed from `device` to `drive` to reflect this.

2. Active-ticket updates: when a duplicate is detected and the ticket is
   still open, the API now compares the incoming title and priority against
   the existing ticket. If the title changed or priority escalated (lower
   number), the ticket is updated and a comment is added explaining what
   changed. Previously the API silently returned "Duplicate ticket" with
   no update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:55:15 -04:00
jared fe9c6b3ee0 Rewrite create_ticket_api.php dedup logic: reopen closed tickets on recurrence
Instead of crashing (PHP 8.2 TypeError) or silently failing on duplicate hash,
the API now:
- Checks for any existing ticket with the same hash (no 24h limit)
- If open/pending/in-progress: returns Duplicate ticket with existing ID
- If closed: reopens the ticket, posts a recurrence comment, returns action=reopened
- If new: creates the ticket as before
- Wraps INSERT in try/catch for mysqli_sql_exception to handle race conditions
  gracefully when multiple nodes POST simultaneously

Also improves the hash function:
- Ceph issues now include a subtype (bluestore_slow, clock_skew, osd_down, etc.)
  so different Ceph warnings get distinct tickets instead of colliding
- LXC storage issues include the container ID so each container gets its own ticket
- Fixed potential null-subject issue in preg_match for missing titles
- Added early input validation (400 + JSON error) before any processing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:40:36 -04:00
jared 570b1749da Fix PHP 8.2 TypeError crash in create_ticket_api.php on missing title
generateTicketHash() passed $data['title'] to preg_match() before any
input validation. In PHP 8.2, preg_match() with null subject throws
TypeError, causing HTTP 500 with empty body. hwmonDaemon saw this as
"Expecting value: line 1 column 1 (char 0)" and failed to create tickets
on all nodes.

Moved input validation before the hash call: missing or empty title now
returns HTTP 400 with proper JSON error instead of crashing. Also removed
the redundant late URL-encoded fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:07:11 -04:00
jared cc509874e7 Fix incomplete HTML escaping in reply textarea (ticket.js)
Line 1575 used .replace(/</g, '&lt;').replace(/>/g, '&gt;') to set
the comment-raw edit textarea content, missing '&' → '&amp;'. Replaced
with lt.escHtml() which escapes all five special HTML characters (&, <,
>, ", ') consistently with the rest of the codebase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:21:54 -04:00
jared 6e1ae01cac Fix recurring ticket schedule edge cases in API and model
- manage_recurring.php calculateNextRun(): expand monthly cap from 28→31
  with proper last-day-of-month clamping (matches model fix); use split
  with ':00' append to handle malformed time strings without crashing;
  fix weekly day array to start at index 1 (not 0) so day=0 never maps
  to empty string and blows up DateTime
- RecurringTicketModel::calculateNextRunTime(): same weekly day array fix
  (start at index 1) to eliminate '' → DateTime exception on day=0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:16:41 -04:00
jared c3ab5c5716 Fix double URL-encoding of Matrix user ID in SynapseHelper
rawurlencode($username) was called on line 38 (encoding the username),
then rawurlencode($matrixId) was called on line 39 encoding the already-
encoded string — causing %20 to become %2520 for usernames with special
characters. Fixed by building $matrixId with the plain username and only
encoding the full Matrix ID once in the URL path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:12:02 -04:00
jared 538baadd57 Add comment skeleton loaders, workflow validation, monthly schedule fix
- TicketView.php: Show 3 lt-skeleton-card placeholders in the comment list
  while "Load more" fetches; skeletons are removed on resolve or error
- ticket.css: Add .comment-skeleton margin spacing
- WorkflowDesignerView.php + manage_workflows.php: Prevent creating/editing
  status transitions where from_status === to_status (client + server check)
- RecurringTicketsView.php: Expand monthly day picker from 28 to 31 days
  (days 29-31 labelled "last day in short months")
- RecurringTicketModel.php: Clamp monthly schedule day to last day of target
  month using format('t') instead of hard-capping at 28

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:09:53 -04:00
jared fbda618fbb Fix path traversal, closed-connection, and ticket ID validation bugs
- download_attachment.php: path traversal check used strpos() without
  trailing DIRECTORY_SEPARATOR, allowing /uploads_evil/* to pass when
  upload dir is /uploads — now checks realPath + DIRECTORY_SEPARATOR prefix
- bulk_operation.php: $conn->close() was called before StatsModel($conn)
  construction; moved close() inside each branch to after all DB use
- upload_attachment.php: ticket ID validated as /^\d{9}$/ (exactly 9
  digits) breaking all tickets below ID 1,000,000,000 — changed to
  /^\d+$/ for any positive integer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 17:57:36 -04:00
jared 01f2dac2d6 Fix session_start guards, add missing API routes, rewrite README
- Added session_status() === PHP_SESSION_NONE guard to six API files
  (custom_fields, revoke_api_key, manage_templates, generate_api_key,
  get_template, manage_recurring) that called bare session_start() after
  RateLimitMiddleware had already started the session
- Registered /api/notifications.php and /api/user_avatar.php in index.php
  router (were missing, served only by direct file access)
- Complete README rewrite: remove all Discord references (Matrix/hookshot
  is the only external notification method), add hwmonDaemon API docs,
  document all TDS v1.2 features (kanban, charts, SLA, command palette,
  notification bell, watcher avatars, @mention, etc.), fix keyboard
  shortcuts table, add Matrix/LDAP env vars to setup section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 17:52:07 -04:00
jared 4433bad2ce Fix manage_workflows bind_param by-reference errors and duplicate session_start
- Extract expression args to local variables before bind_param (PHP 8 requirement)
- Guard session_start with session_status check in manage_workflows
- Remove redundant session_start from bulk_operation (RateLimitMiddleware starts it)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 17:27:02 -04:00
jared 1761f41943 Invalidate stats cache after any ticket-modifying API call
StatsModel::invalidateCache() was never called from any API, so the
60s cached stats persisted after bulk assign/status/priority changes,
ticket updates, assignments, and clones. Dashboard tiles showed stale
counts until the TTL expired.

Added invalidation to the four APIs that affect dashboard stat tiles:
- bulk_operation.php: after successful bulk assign/status/priority
- assign_ticket.php: after successful reassignment
- update_ticket.php: after any successful ticket update
- clone_ticket.php: after successful clone (open_tickets changes)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:39:24 -04:00
jared 2378e56268 Fix bulk assign user search: replace broken combobox with typeahead
The combobox modal used lt-combobox-list but lt.combobox looks for
lt-combobox-dropdown — it returned immediately, wiring nothing.

Replaced with lt.typeahead which is correct for single-select search:
- Filters users client-side as you type (minChars:1, debounced 150ms)
- Shows display_name (username) with highlight on match
- onSelect stores user ID and shows "✓ Name" confirmation below input
- Input auto-focuses when modal opens
- Enter key now selects first result even without arrow-key navigation
  (same fix applied to lt.combobox Enter handler)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:35:32 -04:00
jared 025963a78f Make title column greedy when other columns are hidden
Removes inline max-width/nowrap from title td, moves to CSS with
width:99% so the title column absorbs all available space freed by
hiding other columns. max-width:0 trick ensures overflow ellipsis
still works correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:33:51 -04:00
jared c6037a9ccc Fix ticket age, bulk assign, add column visibility toggle
- TicketView: ticket age was measuring from last update not creation;
  fixed to always use created_at
- dashboard.js: bulk assign used non-existent onSelect callback (no
  selection was ever stored); fixed to onChange with selected[0],
  added max:1 to enforce single-select
- base.js: lt.combobox Enter key only fired when focusedIdx >= 0;
  now falls back to first filtered result when no arrow key used
- DashboardView + dashboard.js + dashboard.css: add COLS ▾ button on
  table header that opens a checkbox panel to show/hide optional
  columns (Ticket ID, Category, Type, Created By, Assigned To,
  Created, Updated); state persisted in localStorage, Reset button
  restores all; core columns (Priority, Title, Status, Actions) always
  visible; data-col attributes added to all th/td for CSS targeting

Notifications bell: was functional all along — was broken by the
notifications.php 500 error (now fixed). Avg resolution: correct,
tickets genuinely take ~158 days average on this dataset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:31:30 -04:00
jared 6c491c1baa Fix close-ticket UX, add cmd palette hint, breadcrumb, image lightbox
- ticket.js: status change requiring a comment now shows an inline
  modal with a textarea — comment is actually posted before the status
  changes, instead of just warning the user and changing anyway
- layout_header.php: add ⌘K button in header so users can discover
  the command palette; also removes inline onclick in favor of JS
  (CSP-safe via nonce script block already present)
- TicketView.php: upgrade breadcrumb to lt-breadcrumb markup with
  ticket title preview (truncated at 45 chars) and aria-current
- ticket.js + ticket.css: image attachments now render as clickable
  thumbnails (3rem×3rem) that open in lt.lightbox; non-image files
  keep the icon display unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:15:40 -04:00
jared 6eae9ef816 Add command palette (Ctrl+K / Cmd+K) globally
Adds lt-cmd-overlay HTML to layout_header.php and initializes
lt.cmdPalette with commands for: navigation (Dashboard, New Ticket),
filters (My Tickets, Unassigned, P1 Critical), admin pages (if admin),
and recent tickets (last 5 viewed, stored in localStorage).

TicketView.php records each viewed ticket ID to localStorage under
lt_recent_tickets so the command palette can surface them as Recent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:39:23 -04:00
jared bc88ba3612 Fix notifications 500 (audit_id column), smart resolution time units
- notifications.php: audit_log PK is audit_id not log_id; alias all
  three queries with audit_id AS log_id to fix 500 error
- DashboardView: avg resolution time now picks best unit automatically
  (min < 1h, hr < 48h, days < 14d, wks otherwise) with full hours
  shown in title tooltip; adds lt-stat-unit CSS for the suffix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:32:02 -04:00
jared 5e04478586 Fix parse error in notifications.php: escape inner quotes in LIKE string
The $statusSql double-quoted string contained '%"status":%' which caused
PHP to terminate the string at the inner double quotes, resulting in a
parse error (unexpected identifier 'status') on the beta server.

Also cleared stale stats cache that stored by_assignee in old name=>count
map format instead of the current array-of-objects format.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:25:26 -04:00
jared 9494df2bf9 Add timezone and notif_last_seen to user_preferences valid keys whitelist
Both keys were silently dropped on batch save (the for-loop just
continued on unknown keys). timezone is sent by saveSettings() and
notif_last_seen is written by the notifications mark-read endpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:01:38 -04:00
jared ac05b212b2 Fix performAdvancedSearch ReferenceError, settings save, sort reset, notifications 500, CSP
DashboardView.php: wrap performAdvancedSearch in a closure so it is
resolved at event-fire time rather than listener-registration time
(advanced-search.js loads later via pageScripts so the bare identifier
reference caused ReferenceError).

DashboardView.php: reset sort URL to page=1 so sorting all pages
instead of staying on the current page.

dashboard.js: add missing save-settings and close-settings cases to
the click delegation handler (were removed in a prior session under
the assumption they were in dashboard.js, but they were not).

notifications.php: replace JSON_EXTRACT-based comment join (not
universally supported) with a two-step PHP filter: fetch owner/watcher
ticket IDs first, then filter raw comment rows in PHP. Also fix the
status change LIKE pattern to match the actual logTicketUpdate format
{"status": {"from": ..., "to": ...}}.

SecurityHeadersMiddleware.php: add https://cdn.jsdelivr.net to
connect-src so Chart.js source maps load without CSP violations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:53:06 -04:00
jared df6c4de196 Fix notification comment query, status title, and is-hidden visibility
notifications.php: comment notifications never fired because the query
used action_type='comment'/entity_type='ticket' but logCommentCreate
logs action_type='create'/entity_type='comment'. Fix query to match
actual log format and extract ticket_id from details JSON.

notifications.php: status change notification titles always showed
"? → ?" because code read details.old_value/new_value but logTicketUpdate
stores the delta as {"status": {"from": ..., "to": ...}}.

base.css: move .is-hidden to base.css (global) — it was only defined in
ticket.css, so on the dashboard the ticket-preview popup had no hide
rule applied and was visible in the DOM at all times.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:47:39 -04:00
jared 2ccf4f2261 Clarify comment: @mention highlight skips markdown-rendered elements
markdown.js already calls renderMarkdownElements() on DOMContentLoaded
for all [data-markdown] elements; ticket.js only processes plain-text
comments to avoid double-rendering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:44:14 -04:00
jared dcbe6fb383 Fix double-firing event handlers, non-bubbling keyboard status event, and saved filter status type
- Remove duplicate edit-comment/delete-comment cases from TicketView.php inline
  script — ticket.js already handles them. Double-call of editComment() would
  immediately open then close the edit form (second call sees .editing → cancels)
- Fix keyboard shortcut 1-4 status change: dispatchEvent(new Event('change'))
  was non-bubbling (default), so the document-level change delegation in TicketView
  never received it. Now uses { bubbles: true } so updateTicketStatus() fires correctly
- Fix saved filter status type: getCurrentFilterCriteria() was saving status as a
  joined string "Open,Pending" but pill-click handler called .join() expecting an array
  (TypeError swallowed by try/catch → status filter silently not applied). Now saves
  as array; applySavedFilterCriteria handles both arrays and legacy strings
- Pill-click handler also updated to handle both array and string status formats

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:40:16 -04:00
jared 914c33ecf3 Fix CSP-blocked chart scripts, undefined CSS classes, and double-firing click handlers
- Add nonce to charts and ticket-preview drawer inline <script> blocks in
  DashboardView.php (both were CSP-blocked — charts never rendered)
- Add .lt-modal-xs (280px) to base.css — used by quickStatus/quickAssign
  modals but was undefined, causing them to use full modal width
- Fix showConfirmModal in utils.js: class="text-center" → "lt-text-center"
  (undefined class); escape newlines as <br> so multi-line messages render
- Remove duplicate click-handler cases from DashboardView.php inline script
  that were already handled by dashboard.js, preventing double-firing
  (export-tickets, open-settings, remove-filter, etc. were all called twice)
- Fix manual-refresh action to use lt.autoRefresh.now() instead of bare
  window.location.reload() so modal/focus guards are respected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 23:00:35 -04:00
jared d588590989 fix: ticket preview popup wrong position and persists after interactions
- position:fixed popup was adding window.scrollX/scrollY to viewport coords
  from getBoundingClientRect(), making it appear far below link when scrolled
- Off-screen check compared against innerHeight + scrollY instead of innerHeight
- Added clamp to prevent negative coords (popup clipped off top/left edge)
- Hide preview on scroll, modal open, and pagination clicks (capture phase)
  so stale popup doesn't linger after user navigates away

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:51:39 -04:00
jared b7b6884bb0 fix: add missing CSS classes + clean up remaining inline styles
- Add .lt-modal-sm (max 360px) and .lt-modal-header--danger variant used
  in JS-generated bulk delete confirmation modal (no CSS = unstyled header)
- Add .lt-badge-sm for compact inline badges (comment counts, group tags)
- Add .lt-kv-row { display:contents } with .lt-kv-label/.lt-kv-value rules
  (was missing from previous commit — added in base.css)
- Replace style="text-align:center" with .lt-text-center in JS modal body
- Replace style="flex-direction:column" with .lt-flex-col on .lt-btn-group

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:50:13 -04:00
jared 54887ffa24 fix: kanban not loading on refresh + modal horizontal scroll + lt-kv-row CSS
Kanban restore bug:
- set-view-mode click handler called populateKanbanCards() directly but never
  called setViewMode(), so ticketViewMode was never saved to localStorage
- DOMContentLoaded restore checked ticketViewMode (never written) — it should
  check lt_activeTab_<path> which lt.tabs.init() actually saves
- Fix: delegate to setViewMode() from the click handler; DOMContentLoaded
  reads lt_activeTab_<path> and calls populateKanbanCards() when tab-kanban

Settings modal horizontal scroll:
- .lt-modal-body was missing overflow-x: hidden; content wider than the modal
  (e.g. kbd elements with white-space: nowrap) caused horizontal scrollbar
- Added overflow-x: hidden + min-width: 0 to .lt-modal-body

Missing lt-kv-row / lt-kv-label / lt-kv-value CSS:
- These classes were used in TicketView, DashboardView, admin views but had
  no primary CSS rules (only a light-theme color override existed)
- Without rules, lt-kv-row divs were block-level grid children consuming one
  grid cell each, making lt-kv-label/value stack inside wrong columns
- Added display:contents on lt-kv-row so children participate directly in
  the lt-kv-grid 2-column grid; lt-kv-label/value get padding, border, and
  min-width:0 + overflow-wrap:break-word to prevent grid column blowout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:45:43 -04:00
jared 613886068d fix: sanitize FULLTEXT boolean mode search to prevent MySQL parse errors
User input containing MySQL boolean operators (+, -, (, ), ~, *, ", @)
was passed directly to MATCH...AGAINST in BOOLEAN MODE, causing MySQL to
parse them as search operators rather than literals. Input like '(test)'
or '-keyword' would result in a MySQL syntax error / empty results.

Strip boolean mode special chars before building the FULLTEXT term;
the raw search string is still used unchanged for the LIKE fallback parts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:40:25 -04:00
jared 847d6b2656 fix: malformed img tag in header avatar + notif footer inline styles
- Avatar img tag was missing closing > — the endif fired before the tag
  closed, causing the initials span to be parsed as an attribute value;
  this would silently break the avatar fallback when image fails to load
- Replace style="width:100%;text-align:center" on notif footer link with
  lt-w-full lt-text-center utility classes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:38:39 -04:00
jared c2cd923d32 fix: RecurringTicketModel INSERT bind_param type string mismatch
next_run_at was typed 'i' (int) but stores a datetime string → should be 's'.
is_active was typed 's' (string) but stores 0/1 boolean → should be 'i'.
Positions 10-11 were swapped: 'ssssiiisssis' → 'ssssiiisssii'.
The UPDATE method already had the correct types; only INSERT was affected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:37:22 -04:00
jared 67a7d769f0 fix: unassigned filter not working + null guards on modal selects
- DashboardController: handle assigned_to='unassigned' before validateUserId()
  which discarded the string, causing the filter to never reach TicketModel;
  model already correctly converts 'unassigned' to IS NULL in SQL
- dashboard.js: add null guards before .value access on dynamically-created
  modal selects (bulkPriority, bulkStatus, quickStatusSelect)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:35:04 -04:00
jared 84b104a501 fix: various inline style cleanup, a11y improvements, and bind_param bug
- Replace style="text-align:center" with .lt-text-center utility class in
  WorkflowDesignerView, CustomFieldsView, error_403, error_404, DashboardView JS string
- Replace style="margin-top:..." with .lt-mt-sm utility in WorkflowDesignerView
- Switch comment-edit-raw data-store textareas to .is-hidden class (TicketView PHP
  + JS-rendered; ticket.js template literal) — these are never shown, only read via .value
- Add aria-describedby="visibilityGroupsHint" + id on hint <p> in CreateTicketView
- Fix bind_param type string bug in manage_workflows.php PUT handler: 'ssiiiii' → 'ssiiii'
  (7 type chars for 6 params caused binding error on workflow transition updates)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:29:52 -04:00
jared ff109a710c fix: remove CSP-blocked inline event handlers (onerror, onclick)
- Remove all onerror="this.style.display='none'" from avatar imgs in
  layout_header.php, DashboardView.php, and TicketView.php (PHP + JS)
- Replace onclick SLA dismiss with data-action="dismiss-priority-banner"
  attribute; handler wired via existing click delegation in TicketView.php
- Global capture-phase error delegation in layout_footer.php handles all
  avatar image failures by adding .lt-avatar-img-err class (CSS display:none)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:15:45 -04:00
jared 1ab374531c fix: avatar image overlays initials, chart canvas responsive sizing
Avatar bug:
- base.css: .lt-avatar now position:relative; img is position:absolute inset:0
  so a loaded image covers the initials span (fixes img+initials shown together)
- base.css: .lt-avatar img.lt-avatar-img-err { display:none } — CSS hook for error state
- layout_footer.php: capture-phase error event delegation on .lt-avatar imgs
  replaces blocked inline onerror handlers (CSP has no unsafe-inline in script-src)

Chart bug:
- DashboardView: replaced display:flex section-body containers with a
  position:relative; width:100%; height:170px div wrapper for each canvas
  (Chart.js responsive:true reads parentNode dimensions; flex containers
  give canvas zero intrinsic width causing 0×0 render = empty charts)
- Removed has-lt-overlay from chart frames (no overlay div was injected)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:25:27 -04:00
jared bfe00ea0f6 fix: add lt-toggle--sm CSS variant for compact toggle switches in comment bar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:46:31 -04:00
jared 04b019a8e1 feat: Chart.js donut/bar charts, Flatpickr dates, skeleton loaders, CSP update
- DashboardView: Charts row with 3 panels (priority donut, status donut, category bar)
  using Chart.js from CDN; data passed inline from PHP stats; TDS color palette
- DashboardView: Flatpickr date picker on advanced search date fields with TDS theme overrides
- dashboard.js: showTableSkeleton() shows lt-skeleton-row during filter-triggered reloads
  and auto-refresh; called before all location.reload() with delay
- dashboard.css: Flatpickr TDS theme overrides (dark BG, monospace font, TDS accent colors)
- SecurityHeadersMiddleware: Added cdn.jsdelivr.net to script-src and style-src CSP
  to allow Chart.js and Flatpickr from CDN

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:45:02 -04:00
jared c15defc09b feat: duplicate detection + mark-as-duplicate, lt-toggle preferences in settings
- Dependencies tab: auto-loads potential duplicates via /api/check_duplicates.php
  on first activation; shows 'Mark duplicate' button per result which POSTs to
  ticket_dependencies with type=duplicates and refreshes the dependencies list
- Settings modal: replaced checkboxes with lt-toggle switches for
  notifications_enabled and sound_effects; loads current user prefs on modal open
  and saves via /api/user_preferences.php on SAVE button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:25:58 -04:00
jared 3c29c6ee6f feat: SLA live timer, notification bell, lt-toggle MD, right drawer, kanban drag-drop
- TicketView: SLA banner now shows live HH:MM:SS elapsed + countdown via JS setInterval
  (previously showed static hours from PHP)
- TicketView: Markdown toggles in comment form replaced with lt-toggle switches
- layout_header: In-app notification bell (🔔) with dropdown panel for all users
- layout_footer: Notification JS — polls /api/notifications.php every 60s, badge count,
  mark-all-read, panel open/close with Escape/outside-click
- api/notifications.php (new): Returns assign/comment/status-change events from audit_log
  for current user's tickets and watched tickets; mark-read via user_preferences
- DashboardView: Ticket preview right drawer — Ctrl+click title or ⊙ peek button
  opens lt-drawer-right with ticket summary extracted from table row DOM
- DashboardView: lt.sortable wired on all 4 kanban columns (group='kanban')
  Cross-column drag = status change via POST /api/update_ticket.php with optimistic UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:21:21 -04:00
jared 9916daa904 fix: TDS priority selector in ticket.js, asset versioning in admin views
- updateTicketField() now targets .lt-frame-ticket[data-priority] (TDS v1.2)
  instead of old .priority-indicator / .ticket-container selectors
- All 7 admin views: keyboard-shortcuts.js now uses dynamic ?v={$_v}
  instead of hardcoded unversioned path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 15:48:30 -04:00
jared 6727aeea29 feat: saved filter pills, mention autocomplete CSS, tooltips on dashboard table
- Dashboard: saved filter pills row above active filters bar — loads from API,
  click applies criteria as URL params, hidden when no saved filters exist
- ticket.css: add TDS-styled CSS for @mention autocomplete dropdown (was unstyled)
- Dashboard table: data-tooltip on Title and Assigned To columns for truncated text
  (lt.tooltip.init() auto-called by lt.init(), zero extra JS needed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:06:46 -04:00
jared 0d8edc9d34 feat: trend dots on stat cards, team workload panel, stat model improvement
- Dashboard stat cards now show lt-dot trend indicators (up/warn/idle) based on
  created_today vs closed_today flow — no extra DB query needed
- Add collapsible Team Workload panel showing assignee open ticket counts with
  progress bars (green/cyan/red by load), avatar, and name
- StatsModel.getTicketsByAssignee() now returns proper objects with user_id,
  display_name, open_count (was name-keyed flat array); limit raised to 8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:04:41 -04:00
jared fca4896e0d fix: watcher avatars, dependency TDS styling, asset versions, nav dropdown light theme
- watch_ticket.php GET now returns watcher list (up to 6 users) for avatar group
- TicketView: watcher avatar group rendered next to WATCH button, refreshes on toggle
- Rewrite renderDependencies/renderDependents to use TDS lt-kv-grid/lt-badge/lt-btn classes
- renderDependencies: show lt-alert--warning blocker banner when blocked_by has open tickets
- Fix ALL hardcoded ?v=20260327 asset version strings in CreateTicketView + all admin views
- base.css: fix .lt-nav-dropdown-menu hardcoded background → var(--bg-overlay)
- base.css: add light-theme overrides for nav dropdown menu (background, links, hover)
- ticket.css: add .lt-avatar-group and .lt-avatar--overflow styles for watcher display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:02:30 -04:00
jared c0dfbdbc26 feat: status dots, priority banners, lt-tags, command palette, activity timeline improvements
- Fix DashboardView asset version (was hardcoded 20260327, now uses config ASSET_VERSION)
- Add lt-dot status indicators on dashboard table rows and ticket view toolbar
- Add lt-tag display for Category/Type in ticket read mode (swaps to select in edit mode)
- Add P1/P2 SLA alert banner with elapsed time, progress bar, per-session dismiss
- Wire command palette (Ctrl+K): global nav + admin links via lt.cmdPalette.init()
- Fix cmdPalette.init() call format (flat array, not nested group objects)
- Improve activity timeline: richer formatAction(), better color coding by event type,
  inline status transitions shown in meta row, icon column added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:54:26 -04:00
jared 85afec64ac Add responsive .lt-main.lt-container overrides to match production base.css
Production base.css has per-breakpoint .lt-main.lt-container rules that
explicitly set padding-top with tighter spacing at SM/XS viewports. Adding
these to beta to match — ensures header clearance is bulletproof at all sizes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:13:16 -04:00
jared ec92445a0f Force header clearance via inline style on main element
CSS cascade fixes were correct but browser was serving cached base.css.
Inline style cannot be cached separately and bypasses all cascade issues.
CSS variables still respect media query :root overrides so --header-height
resolves to the correct value (50px SM, 46px XS) at each breakpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:52:22 -04:00
jared 0eab5d40e6 Restore .lt-main.lt-container combined selector — proper cascade fix
The TDS v1.2 sync removed the .lt-main.lt-container combined selector that
was already in the project's base.css. That selector has specificity (0,2,0)
vs single-class (0,1,0), so it always wins over .lt-container padding
shorthand at every breakpoint without needing per-breakpoint overrides.

Also restored flex:1, width:100%, min-width:0 on .lt-main that were dropped.
Removed the incorrect per-breakpoint .lt-main and #main-content hacks added
today which were the wrong approach to the same problem.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:49:00 -04:00
jared 3cfe46050b Fix header overlap with ID selector — unambiguous highest specificity
Use #main-content (specificity 1,0,0,0) to set padding-top at each breakpoint.
This cannot be overridden by any class-based rule regardless of cascade order,
permanently fixing the fixed header overlapping page content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:45:14 -04:00
jared e71f35c041 Fix asset cache-busting — include base.css and base.js in ASSET_VERSION
Previously only dashboard/ticket assets were tracked, so changes to base.css
and base.js were never reflected in the cache-busting version string. Browsers
served stale cached copies, meaning the header padding-top fix never reached
users. Touch base files to bump mtime and force a cache miss immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:43:18 -04:00
jared 6102985f92 Fix header overlap at all breakpoints — restore lt-main padding-top
Every media query that overrides .lt-container { padding } with a shorthand
was clobbering .lt-main { padding-top } because both selectors have equal
specificity and the container rule came later in the file. Added .lt-main
padding-top restores after each affected breakpoint (LG 1024-1279px, MD
768-1023px, 1920px+). The laptop range (LG) was the likely culprit on desktop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:41:33 -04:00
jared e91709798b Fix header overlapping content at mobile breakpoints
In the SM (≤767px) and XS (≤479px) media queries, .lt-container { padding }
shorthand appeared after .lt-main { padding-top } with equal specificity,
causing the shorthand to clobber the header-clearance padding-top. Swap order
so .lt-main always wins.

Also remove redundant lt-scanlines div — body::before in base.css already
renders the scanline overlay globally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:19:22 -04:00
jared 4150e1ced3 Fix lt-scanlines header overlap — move class off body to dedicated div
body::before and body::after are used for background grid/gradient effects.
Adding lt-scanlines to body caused ::after conflict (higher specificity) and
put the scanline overlay at z-index 9998, above the header at z-index 300.

Move lt-scanlines to a dedicated fixed div so pseudo-elements don't conflict
and the header remains fully visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:12:07 -04:00
jared cfdc9e0f37 Sync TDS v1.2 additions: scanlines, cursor, radar, display-field, VT323
- Sync base.css + base.js from web_template (adds lt-scanlines,
  lt-cursor, lt-radar, lt-display-field, --font-crt/VT323 token)
- Add VT323 to Google Fonts link in layout_header.php
- Add lt-scanlines to <body> — CRT scanline overlay, light-mode suppressed
- Replace custom .editable-metadata:disabled CSS override in ticket.css
  with the canonical .lt-display-field class from base.css
- Switch Priority/Category/Type/Visibility selects and visibility-group
  checkboxes in TicketView.php from disabled attribute to lt-display-field
- Update toggleEditMode() in ticket.js to add/remove lt-display-field
  instead of toggling the disabled attribute

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 16:55:12 -04:00
jared 55c6fc81db Fix duplicate users in bulk/quick assign modals; add combobox search
Root cause: DashboardView.php and dashboard.js both had a global
document.addEventListener('click') handler handling the same bulk-assign
and quick-assign actions. Every click fired both handlers, creating two
modals and two API fetches that both appended to the same select element.

Fix: Remove duplicate cases (bulk-*, navigate, view-ticket, quick-*,
set-view-mode, toggle-*, clear-selection) from DashboardView.php's inline
handler. dashboard.js already handles all of these correctly.

Also replace <select> with lt.combobox in both bulk-assign and
quick-assign modals so large user lists are searchable instead of a
long scrolling dropdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:13:10 -04:00
jared fdc6d3d463 Fix ASCII art alignment, readonly input opacity, api key visibility
Use white-space:pre-wrap on description view div so newlines and multiple
spaces are preserved natively — no <br> replacement, ASCII art aligns
correctly since body is already monospace (JetBrains Mono).

Override opacity:1 on readonly API key input so generated keys are fully
readable instead of being faded to 0.45 by base.css [readonly] rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:43:18 -04:00
jared 72d5061867 Fix description line breaks and disabled-field readability
Ticket descriptions are plain text — renderDescriptionView() now always
uses nl2br instead of parseMarkdown(), preventing markdown from mangling
single newlines into run-on paragraphs.

Override base.css opacity:0.45 on disabled .editable-metadata selects
(Priority, Category, Type) so they remain legible at full contrast on
dark/OLED screens in read mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:36:10 -04:00
jared 1d721eecb4 fix: description unreadable in dark mode / OLED — swap disabled textarea for lt-markdown div
Root cause: disabled textarea gets opacity:0.45 + color:var(--text-muted) from
base.css, making it near-invisible on OLED (true-black background).

Fix:
- TicketView: add #ticketDescriptionView (div.lt-markdown) alongside the textarea;
  textarea is now hidden by default (style="display:none"), view div is shown
- ticket.js: renderDescriptionView() renders raw text via parseMarkdown() or nl2br;
  showDescriptionView() / showDescriptionEdit() swap between them;
  toggleEditMode() calls showDescriptionEdit() when entering edit, and
  renderDescriptionView() + showDescriptionView() when returning to read mode
- ticket.css: .ticket-description-view sets full-contrast text-primary/secondary
  colors, min-height, and line-height for comfortable reading

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 18:10:39 -04:00
jared cfb88d9c88 fix: CSRF token staleness causing intermittent 403 on POST actions
Root cause: bootstrap.php rotates the CSRF token on every successful POST,
but most API endpoints called echo json_encode() directly instead of
apiRespond() — so the rotated token was never returned to the client.
The next POST from the same page sent the now-invalid old token → 403.
Refreshing the page loaded a fresh token, making it work once.

Fixes:
- assign_ticket.php, watch_ticket.php: switch to apiRespond()
- saved_filters.php, user_preferences.php: replace all echo json_encode
  calls with apiRespond() (19 and 12 call sites respectively)
- base.js: both apiFetch() and _apiFetchAuth() now update window.CSRF_TOKEN
  whenever a response includes a csrf_token field, keeping the client
  permanently in sync with server-side rotations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:01:18 -04:00
jared a89095cbcc chore: delete applied migrations (001-002, 004-005 all applied to DB)
All SQL migration files have been applied and recorded in the migrations
tracking table. Folder intentionally empty — migrate.php kept as runner
for future one-time schema changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:03:16 -04:00
jared ade1a70214 feat: ticket watchers, fulltext search, single-query pagination, watcher notifications
Ticket watchers:
- api/watch_ticket.php: GET (watch state) + POST (watch/unwatch toggle)
- index.php: route for /api/watch_ticket.php
- TicketView: WATCH/UNWATCH button with live state fetch and toggle
- NotificationHelper::notifyWatchers(): fetches watchers from DB, resolves
  Matrix IDs via Synapse, fires notification to watchers + global list
- add_comment.php, update_ticket.php: call notifyWatchers on comment and
  status-change events respectively

Fulltext search:
- TicketModel::hasFulltextIndex(): detects FULLTEXT index via information_schema
- getAllTickets(): uses MATCH...AGAINST when fulltext index exists, LIKE fallback
  when not yet applied — zero-downtime rollout

Single-query pagination:
- getAllTickets() replaces separate COUNT + SELECT with COUNT(*) OVER() window
  function — one round trip to DB per page load instead of two

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:00:32 -04:00
jared 0acf5e84c3 feat: duplicate link action, watcher migration, fulltext search migration
- CreateTicketView: "Link as duplicate" button on each duplicate result;
  stores chosen ticket ID in hidden field, auto-creates duplicates dependency
  after ticket is saved (TicketController)
- migrations/004: ticket_watchers table (ticket_id, user_id primary key)
- migrations/005: FULLTEXT index on tickets(title, description) for fast
  relevance search replacing LIKE scan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:54:00 -04:00
jared c8181e8076 feat: comment pagination, Matrix integration, Synapse mention resolution
Comment pagination:
- CommentModel: add getCommentCount(), paginated getCommentsByTicketId()
  with getThreadedCommentsPaged() for threading + LIMIT/OFFSET
- TicketController: load first 50 root comments + total count on page load
- api/get_comments.php: new AJAX endpoint for Load More (index.php routed)
- TicketView: Load More button + buildCommentEl() JS renderer for AJAX comments;
  passes totalComments/commentOffset/isAdmin to window.ticketData

Matrix integration:
- NotificationHelper: add sendStatusChangeNotification(), sendCommentNotification(),
  sendMentionNotification(), sendAssignmentNotification() alongside existing
  sendTicketNotification(); internal fire() helper replaces duplicated cURL logic
- SynapseHelper: new helper that resolves SSO usernames → Matrix IDs by querying
  Synapse Admin REST API directly (no caching, no stale data)
- config.php: add SYNAPSE_ADMIN_URL, SYNAPSE_ADMIN_TOKEN, MATRIX_NOTIFY_COMMENTS,
  MATRIX_NOTIFY_ASSIGNMENTS config keys (all from .env)
- api/update_ticket.php: fire status-change notification after successful save
- api/add_comment.php: resolve @mentioned usernames via SynapseHelper and fire
  mention notification; fire general comment notification when MATRIX_NOTIFY_COMMENTS=1
- api/assign_ticket.php: fire assignment notification (resolves assignee via Synapse)
  when MATRIX_NOTIFY_ASSIGNMENTS=1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:34:16 -04:00
jared cc3f667d4c Wire optimistic locking, visibility audit log, full ticket export
Optimistic locking:
- TicketView now includes updated_at in window.ticketData
- ticket.js saveTicket() sends expected_updated_at on every save so
  the server can detect concurrent edits
- On conflict response, shows a clear toast: "ticket was modified by
  someone else while you were editing — reload to see latest version"
- On success, syncs window.ticketData.updated_at from server response
  so subsequent saves use the correct lock key
- update_ticket.php now returns updated_at in success response

Visibility audit log:
- updateVisibility() result is now checked; on success, logs a delta
  entry to the audit trail with from/to visibility and groups so the
  timeline shows who changed visibility and when

Full ticket export:
- export_tickets.php now accepts format=full with a single ticket_id
- Produces a JSON file containing ticket fields, flat comment list
  (with author, timestamps, text), and the full audit timeline
- Access-controlled: respects canUserAccessTicket() before exporting
- EXPORT button added to ticket toolbar linking directly to the endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:19:01 -04:00
jared 2fdd42b45b UX and architecture fixes: bulk-delete, template guard, statuses config
Bug fixes:
- bulk-delete action called undefined bulkDelete() — wired to the
  existing showBulkDeleteModal() so the confirmation modal actually shows

UX:
- Template loader now checks for existing title/description and asks
  for confirmation before overwriting user-typed content
- Visibility select shows a dynamic hint paragraph that updates when
  the user changes the selection (public/internal/confidential)

Architecture:
- TICKET_STATUSES added to config as single source of truth; all
  hardcoded ['Open','Pending','In Progress','Closed'] arrays in
  DashboardView now read from config; bulk-status modal in dashboard.js
  reads window.TICKET_STATUSES (set from PHP) with array fallback
- ASSET_VERSION now auto-computed from max mtime of dashboard/ticket
  CSS+JS files so browsers always pick up changes on deploy; manual
  override still available via ASSET_VERSION in .env
- Removed 10 dead standalone stat methods from StatsModel (getOpenTicketCount,
  getClosedTicketCount, getTicketsByPriority, etc.) — all superseded by
  the consolidated fetchAllStats() queries, never called externally

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:09:29 -04:00
jared 277daf6f00 Remove dead TicketController::update() method
No route in index.php ever invokes this method — all ticket updates
go through api/update_ticket.php. The method also lacked authorization
checks, making its removal strictly safer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:32:55 -04:00
jared f709e98bd3 Security: add authorization checks to ticket_dependencies API
- POST /ticket_dependencies: verify user can access both the source
  ticket and the target ticket before creating a dependency
- DELETE by ticket IDs: verify user can access source ticket; also
  validate dependency_type against the allowed whitelist
- DELETE by dependency_id: look up dependency's ticket before deletion
  and verify user can access it, preventing IDOR
- custom_fields.php: validate json_decode returns an array on POST/PUT;
  add http_response_code(400) to all error responses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:26:01 -04:00
jared e6b6a2a88c Security/correctness: visibility filtering, Content-Type headers, group validation
- TicketModel::getAllTickets() now accepts optional $user param and applies
  getVisibilityFilter() so non-admin users cannot see internal/confidential
  tickets they lack access to from the dashboard listing
- DashboardController passes $GLOBALS['currentUser'] to getAllTickets()
- clone_ticket.php: move Content-Type header to top so all error paths send
  correct JSON content type
- AuthMiddleware: filter group names from HTTP header to [a-z0-9_-] only,
  preventing header injection via malformed group names
- add_comment.php: return HTTP 201 on success, 500 in catch block
- update_comment.php, delete_comment.php: return 500 in catch blocks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:23:16 -04:00
jared f983269f93 Fix file upload security, bind_param mismatch, and cookie flags
- upload_attachment.php: derive stored file extension from validated MIME type
  instead of user-supplied filename, preventing executable extension attacks
  (e.g. a PHP file renamed to evil.txt would now be stored as .txt)
- CustomFieldModel.php: fix bind_param type string in updateDefinition()
  'sssssiiiii' (10 chars) → 'sssssiiii' (9 chars) to match 9 SQL placeholders
- RateLimitMiddleware.php: replace MD5 with SHA256 for rate limit file hashing
- user_preferences.php: add httponly, secure, samesite=Lax flags to ticketsPerPage
  cookie to prevent XSS/CSRF cookie theft

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:14:18 -04:00
jared 7be283423a Fix loose comparisons, missing response codes, and session handling
- ticket.js: escape dependency_id with lt.escHtml() in data attribute
- assign_ticket.php: strict (int) cast for ticket_id (> 0 check), authorization
  comparisons, and add missing http_response_code(400) on invalid user ID
- TicketView.php: strict (int) cast for priority select, assigned_to select,
  and comment ownership check
- CommentModel.php: strict (int) cast for parent_comment_id thread comparison
- UserModel.php: strict (int) cast for is_admin check
- export_tickets.php: conditional session_start() to avoid double-start warning

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 17:39:46 -04:00
jared 2e450dc01d Apply web_template gap analysis improvements (P1-P3)
P1-A: Fix CSP - add fonts.googleapis.com to style-src, fonts.gstatic.com to font-src
P1-B: CSRF token rotation - add rotateToken() to CsrfMiddleware; bootstrap.php rotates
      after successful validation and stores in $GLOBALS['_new_csrf_token']; add
      apiRespond() helper to append token to responses; lt.api interceptor in
      layout_footer.php auto-updates window.CSRF_TOKEN from responses
P1-C: Styled 403/404 error views with TDS layout instead of raw text; index.php now
      uses requireAdmin() helper eliminating 7 duplicated guard blocks (P3-D)
P2-A: Remove duplicate JS-generated keyboard help modal from keyboard-shortcuts.js;
      '?' key now routes to static #lt-keys-help modal in footer
P2-B: Asset versioning driven by config ASSET_VERSION key; base.css and base.js get
      ?v= cache-busting in layout_header.php
P2-C: Add data-theme="dark" to <html> tag to prevent FOUC on light-mode users
P2-E: Escape status value in dashboard.js hover preview class attribute via lt.escHtml()
P2-F: Replace bespoke showLoadingOverlay() with lt-spinner / lt-loading-text from
      base.css; add .lt-loading-overlay wrapper CSS to dashboard.css
P2-G: Add keyboard-shortcuts.js to all 7 admin views so J/K nav and ? help work
P3-A: APP_NAME, APP_SUBTITLE, APP_VERSION driven from config.php; layout header/footer
      use config values instead of hardcoded strings
P3-G: Replace custom initTableSorting() with lt.sortTable.init() which manages aria-sort

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 17:02:40 -04:00
jared f0abadfc57 Fix 500 error for non-admin users on dashboard
StatsModel queries used `FROM tickets WHERE` with no table alias, but
getVisibilityFilter() returns SQL referencing `t.visibility`. Admins
were unaffected because they get `1=1` with no column references.
Added `t` alias to all three tickets queries that use $visSQL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:42:39 -04:00
jared d33f761a55 Fix loose comparisons in authorization checks
- TicketModel.php: fix bind_param "sssi"→"issi" for ticketId in addComment()
- TicketModel.php: use strict (int) cast === for confidential ticket access check
- update_ticket.php: use strict (int) cast !== for creator/assignee auth check
- AttachmentModel.php: use strict (int) cast === for upload ownership check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:35:48 -04:00
jared cfbef029cb Fix bind_param type mismatches and integer validation
- TemplateModel.php: fix bind_param "ssssiii" -> "sssssii" (5 strings not 4)
- manage_workflows.php: fix bind_param 'ssiiii' -> 'ssiiiii' (4 int columns)
- download_attachment.php, delete_attachment.php, get_template.php: replace is_numeric()
  with strict int cast+equality check to reject floats and scientific notation
- manage_recurring.php: validate JSON input before accessing schedule_type key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:33:48 -04:00
jared 5242d42fa7 Fix type safety and TDS class naming issues
- bulk_operation.php: replace is_numeric() with strict int cast+equality to reject scientific notation
- AttachmentModel.php: fix bind_param type strings (s→i for integer ticket IDs)
- CommentModel.php: use strict !== comparison with (int) cast for user_id ownership checks
- ticket.js: replace all non-TDS class names (text-amber→lt-text-amber, btn→lt-btn variants, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:29:28 -04:00
jared d8e6dcf7fa fix: CSS nesting conflict, dashboard.js dead code removal, admin view escaping
CSS:
- ticket.css: use combined .comment.thread-depth-N selectors to resolve the
  margin-left conflict between .comment-reply and .thread-depth-N classes

dashboard.js:
- Remove legacy initStatusFilter() (superseded by TDS v1.2 sidebar filters)
- Remove initTableSorting() call (client-side sort conflicts with server ?sort=)
- Remove quickSave() + saveTicket() (old hamburger-menu ticket page functions)
- Remove global loadTemplate() (duplicate of IIFE-scoped version in CreateTicketView)
- Remove generateSkeletonRows/Comments/Stats helpers (never called, used
  unregistered CSS class names like .skeleton-row-tr)
- Remove "force dark mode" lines that overrode the user theme preference
- Fix non-TDS CSS classes in modal templates: text-center → style, text-green →
  lt-text-cyan, mb-half → lt-mb-xs, modal-warning-text → lt-text-danger

Admin views:
- RecurringTicketsView: replace innerHTML += loop with createElement/appendChild
  (avoids serial DOM re-parsing on each iteration)
- AuditLogView: add htmlspecialchars() to action_type option values (consistency)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:34:34 -04:00
jared 6b76496640 fix: CSRF on ticket create form, DOM-safe duplicate list, audit-log param validation
- TicketController::create: validate csrf_token from POST before processing
- CreateTicketView: emit hidden csrf_token field; replace innerHTML duplicate
  list with DOM methods to prevent any XSS path; guard checkDuplicates() with
  lt.api availability check
- index.php audit-log: allowlist action_type; validate date_from/date_to as
  YYYY-MM-DD before passing to query

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:26:52 -04:00
jared b40c404828 fix: ldap_get_entries returns raw binary, remove incorrect base64 decode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:53:49 -04:00
jared 18bf1fde0e feat: LDAP avatar support via lldap
- Create tinker-tickets service account in lldap (lldap_strict_readonly)
- Add /api/user_avatar.php: binds to lldap, fetches avatar attribute,
  caches JPEG to uploads/avatars/, returns 404 sentinel for missing photos
- Install php8.2-ldap on LXC 132 (beta) and LXC coding server
- Update layout_header.php: show lt-avatar with photo overlay + initials fallback
- Update TicketView.php: comment avatars use photo overlay pattern
- Add .lt-avatar-img / .lt-avatar-initials CSS for photo-over-initials layout
- Add LDAP_* config keys to config.php and .env.example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:47:08 -04:00
jared 87f878ee6b Fix XSS: escape user_name and created_at in reply DOM injection
submitReply() built a replyDiv.innerHTML template literal using
data.user_name (API response) without escaping — an attacker-controlled
display name could inject arbitrary HTML. Fix: wrap all API-sourced
string values in lt.escHtml() within the template.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:42:27 -04:00
jared 82aa4bf5de Harden attachment deletion and template CRUD validation
- delete_attachment.php: add realpath() path traversal check before
  unlink() — mirrors the defense-in-depth already in download_attachment.php;
  also cast ticket_id to int when building the path
- manage_templates.php: add input validation to POST and PUT handlers:
  required field checks, max length caps (name 100, title 255, desc 64KB),
  allowlist validation for category/type, priority clamped to 1-5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:41:22 -04:00
jared e2c23d0405 Fix XSS: escape userName in reply form insertAdjacentHTML template
showReplyForm() read userName from data-user attribute (decoded by
the browser from HTML entities) and injected it unsanitized into
insertAdjacentHTML() — any HTML special chars would be parsed as markup.
Fix: wrap with lt.escHtml() before interpolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:38:30 -04:00
jared 170bd86aa6 Show only changed fields (delta) in ticket activity timeline
Before: entire ticket data was logged and shown in the activity tab.
After: compare old vs new values before saving; log only fields that
actually changed as { field: { from: '...', to: '...' } } pairs.

- TicketController.php: fetch old ticket before update, compute delta
- api/update_ticket.php: same fix for the API endpoint (currentTicket
  already fetched for auth, reuse it for delta comparison)
- TicketView.php: render delta format as "Field: old → new" with color;
  truncate long values (description) at 60 chars; keep legacy flat format
  as fallback for older log entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:35:01 -04:00
jared 3bb4792635 Fix header overlap, is-hidden missing globally, and CreateTicketView CSS
- base.css: add .lt-main.lt-container combined selector (specificity 0,2,0)
  to prevent responsive .lt-container padding shorthand from overriding
  the fixed-header clearance padding-top — affected all viewports < 1280px
- base.css: add .is-hidden { display: none !important } globally; it was
  only defined in ticket.css so dashboard ticketPreview popup rendered
  as a green box at 0,0 on page load instead of being hidden
- CreateTicketView.php: add dashboard.css to pageStyles so create-ticket-
  meta-grid, lt-form-hint, visibility-groups-list, duplicate-list classes
  are available (they were undefined when only ticket.css was loaded)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:30:00 -04:00
jared b42597c927 Fix CSS variables, missing utility classes, API hardening, and audit log UX
- base.css: add --lt-border/--lt-surface aliases so dashboard.css respects
  theme instead of using hardcoded fallback colors
- base.css: add lt-select-sm/lt-input-sm compact size variants (used in 15+
  places), lt-msg-danger alias for lt-msg-error, lt-form-hint--warn,
  lt-font-mono utility class
- audit_log.php: cap ?limit= at 500 to prevent DoS via oversized queries
- ApiKeysView.php: replace deprecated execCommand('copy') with lt.copy();
  add integer casts on api_key_id in id attr and data-id
- AuditLogView.php: rebuild pagination with windowed prev/next/ellipsis
  pattern matching DashboardView; integer cast on user_id select option

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:22:12 -04:00
jared e721b33911 Align UI with web_template TDS v1.2 standards
- Replace lt-chip priority badges with lt-badge lt-badge-p[1-4] across
  DashboardView, TemplatesView (matches web_template sticky table pattern)
- Add lt-theme-btn theme toggle to header-right; wire lt.theme.toggle()
- Replace ASCII art empty state with lt-empty-state component in dashboard
- Standardize tab wrapper lt-tabs → lt-tab-bar in Dashboard and TicketView
- Add missing lt-keys-help modal to layout_footer (fixes ? key doing nothing)
- Add lt-cmd-overlay command palette container + lt.cmdPalette.init() nav
- Add .lt-timeline-action CSS rule (used in TicketView, was undefined)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:06:40 -04:00
jared d7775e62ec Fix layout regressions, nav drawer structure, and security issues
- base.css: add width:100%+min-width:0 to .lt-main so flex column body
  doesn't shrink content due to margin:0 auto from .lt-container
- layout_header.php: restructure mobile nav drawer to match web_template
  exactly (nav-drawer-links nav, direct <a> links, section div, no ul/li
  wrapper, overlay after drawer); fix lt-nav-overlay id mismatch with
  base.js; rename lt-header-username -> lt-header-user (matches CSS);
  add JSON_HEX_TAG to all inline json_encode calls (closes </script> XSS)
- base.css: add lt-kv-row/label/value aliases (display:contents pattern
  used in web_template v1.2 kv-grid); add lt-badge-sm variant
- Admin views: add missing .catch() on editField/editRecurring/loadUsers;
  add JSON_HEX_TAG to json_encode in TemplatesView/WorkflowDesignerView
- TicketView: add JSON_HEX_TAG to all ticket-data json_encode calls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 12:43:24 -04:00
jared 51f6991f9d feat: nano-style footer bar, missing utility classes, CSS semantic vars
- layout_footer.php: add lt-footer with context-sensitive keyboard hint bar
  ([ ~ ] HOME | [ / ] SEARCH | [ + ] NEW | [ * ] CFG | [ ? ] HELP)
  Context adapts for dashboard, ticket, and admin pages
- layout_footer.php: wire show-keyboard-help and open-settings for all pages
- base.css: body { display:flex; flex-direction:column } + lt-main { flex:1 }
  so footer sticks to bottom of viewport on short pages
- base.css: add lt-flex-gap-xs/sm/md/lg and lt-flex-align-start/center/end
  (were used across all views but never defined — causing broken layouts)
- base.css: add --lt-danger/amber/cyan/success/text-primary CSS variables
  (referenced in ticket.css and dashboard.css fallbacks but never declared)
- base.css: add lt-text-danger/warning/success/info/primary utility classes
  (used in TicketView, DashboardView, admin views but not defined in base.css)
- DashboardView.php: remove ascii-banner.js (loaded but never called)
- TemplatesView.php: fix priority badge from lt-p* to lt-chip component

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 20:16:05 -04:00
jared 9bdeaf7731 fix: deep audit — wire TDS v1.2 components, fix kanban/tabs/bulk/avatar
- ticket.js: fix showTab() early return preventing attachments/deps from loading
- ticket.js: fix performStatusChange() overwriting lt-status-* classes
- dashboard.js: fix updateSelectionCount() using is-visible instead of style.display
- dashboard.js: fix populateKanbanCards() to use #kanban-col-* IDs (TDS v1.2)
- dashboard.js: fix setViewMode() removing references to old non-TDS elements
- dashboard.js: remove mobile-bottom-nav injection (no CSS existed for it)
- dashboard.css: add full lt-kanban-card component styles with priority accents
- dashboard.css: add mobile sidebar overlay, filter toggle, ticket preview popup CSS
- DashboardView.php: replace priority badges with lt-chip component
- TicketView.php: add lt-avatar with initials to comment author display
- ApiKeysView.php: enhance API usage section with lt-code-block component + curl example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 19:58:14 -04:00
jared 79c2d2b513 feat: complete TDS v1.2 redesign across all views
Full application redesign using Terminal Design System v1.2 (lt-* class
system). Introduces shared layout_header/footer partials, upgrades
base.css/base.js to TDS v1.2, and rewrites all views (Dashboard, Ticket,
CreateTicket, and all 7 admin views) with lt-frame, lt-table, lt-modal,
lt-stats-grid, lt-kv-grid, and data-action event delegation patterns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 19:05:42 -04:00
jared 1989bcb8c8 Migrate status and priority display to lt-status/lt-priority design system classes
DashboardView.php:
- Table status column: replace status-{slug} with lt-status lt-status-{slug} for consistent [● Status] bracket decoration from base.css
- Table priority column: replace raw number with lt-priority lt-p{N} empty span for [▲▲ P1 CRITICAL] style badges

dashboard.js:
- Kanban card priority badge: replace card-priority p{N} with lt-priority lt-p{N} to use the design system badge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:25:49 -04:00
jared 0a2214bfaf Improve web_template compliance: lt.bytes.format, lt.tableNav, lt.statsFilter
- ticket.js: replace custom formatFileSize() with lt.bytes.format() from web_template base.js; remove the now-redundant local function
- DashboardView.php: add id="tickets-table" and wire lt.tableNav.init() for j/k/Enter keyboard row navigation
- DashboardView.php: add lt-stat-card class + data-filter-key/data-filter-val to open/critical/closed stat cards; wire lt.statsFilter.init() + window.lt_onStatFilter so clicking a stat card filters the ticket list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:07:49 -04:00
jared e7d01ef576 Return 404 (not 403) for inaccessible tickets in TicketController
Returning 403 Forbidden leaks the existence of tickets to users who
should not know about them. Use 404 Not Found consistently across all
access-controlled endpoints to prevent enumeration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:47:28 -04:00
jared a403e49537 Use canUserAccessTicket() in clone_ticket.php; fix README bootstrap entry
- clone_ticket.php: replace custom visibility check with centralized canUserAccessTicket(); return 404 (not 403) for inaccessible tickets
- README.md: remove bootstrap.php from the API endpoints table (it's a shared include, not a public endpoint); correct its project structure description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:47:03 -04:00
jared 06b7a8f59b Consolidate showConfirmModal into utils.js, remove duplicate from dashboard.js
utils.js is loaded on all pages (dashboard, ticket, admin views) before dashboard.js.
Moving the canonical definition there and removing the guard + the copy in dashboard.js
eliminates the redundant redefinition on every page load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:44:46 -04:00
jared 9f1a375e5a Apply visibility filtering to dashboard statistics
StatsModel.getAllStats() now accepts a user array and applies the same
getVisibilityFilter() logic used by ticket listings. Admins continue to
share a single cached result; non-admin users get per-user cache entries
so confidential ticket counts are not leaked in dashboard stats.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:44:01 -04:00
jared 84cc023bc4 Enforce ticket visibility on attachment and update endpoints
- delete_attachment.php: check canUserAccessTicket() before allowing deletion; return 404 (not 403) for inaccessible tickets to prevent existence leakage
- upload_attachment.php: verify ticket access on both GET (list) and POST (upload) before processing
- update_ticket.php: pass currentUser to controller; add canUserAccessTicket() check before permission check; return 404 for inaccessible tickets instead of leaking existence via 403

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:42:47 -04:00
jared 164c2d231a Fix visibility enforcement and register missing API routes
Security fixes:
- add_comment.php: verify canUserAccessTicket() before allowing comment creation
- assign_ticket.php: use canUserAccessTicket() to prevent info leakage via 403 vs 404
- check_duplicates.php: apply getVisibilityFilter() so confidential ticket titles are not exposed in duplicate search results
- ticket_dependencies.php: verify ticket access on GET before returning dependency data

Route registration:
- Register 7 previously missing API endpoints in index.php: custom_fields, saved_filters, audit_log, user_preferences, download_attachment, clone_ticket, health

Frontend:
- ticket.js: fill empty catch block and empty else block in addComment() with proper error toasts

Documentation:
- README.md: document all API endpoints and update project structure listing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:39:02 -04:00
jared ce95e555d5 CSS class migrations: admin views and boot overlay fade-out
- Replace style.display with .is-hidden classList in ApiKeysView, CustomFieldsView, RecurringTicketsView
- Convert boot overlay fade-out from style.opacity to .boot-overlay--fade-out CSS class
- Add .boot-overlay--fade-out rule to dashboard.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:20:55 -04:00
jared f45ec9b0f7 CSS class migrations in CreateTicketView: duplicate warning, visibility groups
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:18:16 -04:00
jared 5a41ebf180 Convert ticket preview popup visibility to use .is-hidden CSS class
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:16:49 -04:00
jared e35401d54e CSS class migrations for ticket page: tabs, visibility, markdown preview, uploads
- Switch tab show/hide from style.display to .tab-content.active CSS class
- Convert visibilityGroupsField, markdownPreview, uploadProgress to use .is-hidden class
- Replace comment text div style.display with classList.add/remove('is-hidden')
- Add .is-hidden utility class to ticket.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:13:55 -04:00
jared 913e294f9d CSS class migrations: stat-card cursor, view toggle, bulk actions visibility
- Replace stat-card cursor:pointer inline style with CSS rule
- Convert view toggle (table/card) to use .is-hidden CSS class
- Convert bulk-actions and export-dropdown to use .is-visible class
- Add .is-hidden/.is-visible utility rules to dashboard.css
- Remove duplicate lt.keys.initDefaults() call from dashboard.js
- Remove redundant setTimeout from view mode restore
- Add lt.keys.initDefaults() to dashboard.js (was missing entirely)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:08:28 -04:00
jared 28aa9e33ea Fix XSS: escape table data and sanitize sort/pagination URL params
- htmlspecialchars() on category, type, status in table rows
- htmlspecialchars() on data-status attributes in quick-action buttons
- Restrict $currentDir to 'asc'|'desc' to prevent class injection
- htmlspecialchars() on all http_build_query URLs in pagination and sort headers
- htmlspecialchars() on AuditLogView pagination URLs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:40:51 -04:00
jared 31aa7d1b81 Fix JS SyntaxError breaking tabs, textarea scrolling, and XSS escaping
Bug fixes:
- ticket.js: Remove duplicate const textarea declaration inside showMentionSuggestions()
  (was redeclaring a parameter, causing SyntaxError that broke all tab switching)
- ticket.css: Add overflow:hidden + resize:none to disabled textarea so description
  shows full height without internal scrollbar (page scrolls instead)
- ticket.js: Trigger height recalculation when entering edit mode on description

XSS/escaping fixes:
- TicketView.php: htmlspecialchars() on description textarea content (closes </textarea> injection risk)
- TicketView.php: htmlspecialchars() on ticket status and workflow transition status strings
- DashboardView.php: htmlspecialchars() on $cat/$type in input value= attributes
- RecurringTicketsView.php: htmlspecialchars() on composed schedule string

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:34:55 -04:00
jared 7695c6134c Accessibility pass: ARIA roles, label associations, CSS class migrations
- Add role=dialog/aria-modal/aria-labelledby to all 12 modal overlays (JS + PHP)
- Add aria-label="Close" to all 14 modal close buttons
- Add full ARIA combobox pattern to @mention autocomplete (listbox, option, aria-selected, aria-expanded)
- Add for= attributes to admin filter form labels (AuditLog, UserActivity, ApiKeys)
- Remove dead closeOnAdvancedSearchBackdropClick() from advanced-search.js

CSS/JS style cleanup:
- Move .ascii-banner static styles from JS inline to CSS class; add .ascii-banner--glow
- Add .ascii-banner-cursor, .loading-overlay--hiding, .has-overlay, tr[data-clickable]
- Add .animate-fadein/.animate-fadeout/.comment--deleting to ticket.css
- Add .lt-toast--hiding to base.css; remove opacity/transition inline JS
- Remove redundant cursor:pointer JS (already in th{} CSS rule)
- Remove trailing space in lt-select class attributes

Bug fixes:
- base.js: boot overlay opacity inline style was overriding .fade-out class opacity via
  specificity (1000 vs 20), preventing the fade-out animation — removed
- ascii-banner.js: cursor used blink-caret (border-color only) instead of blink-cursor
  (opacity-based), so the █ cursor never actually blinked — fixed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:29:58 -04:00
jared 11f75fd823 Migrate all raw fetch() calls to lt.api, fix CSS fallback values
- Replace all 23 raw fetch() calls in dashboard.js and ticket.js with
  lt.api.get/post/delete — removes manual CSRF header injection,
  manual JSON parsing boilerplate, and response.ok checks throughout
- dashboard.js: 10 calls (inline save x2, template GET, 5x bulk ops,
  quick-status, quick-assign)
- ticket.js: 13 calls (main save, add/update/delete comment x3, reply,
  assign, metadata update, status change, deps GET/POST/DELETE,
  attachments GET, delete attachment)
- Remove stale csrf_token from deleteAttachment body (lt.api sends the
  X-CSRF-Token header automatically)
- Fix CSS variable fallbacks in ticket.css: replace
  var(--text-primary, #f7fafc) and var(--bg-secondary, #1a202c)
  with plain var(--text-primary) and var(--bg-secondary)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:27:46 -04:00
jared e179709fc3 Add lt.autoRefresh, fix showToast in admin, clean up inline styles
- Replace all 8 showToast() calls in ApiKeysView.php with lt.toast.*
  — all toast calls in the codebase now use lt.toast directly
- Add .duplicate-list, .duplicate-meta, .duplicate-hint CSS classes to
  dashboard.css; replace inline styles in duplicate detection JS with them
- Add dashboardAutoRefresh() using lt.autoRefresh — reloads page every
  5 minutes, skipping if a modal is open or user is typing in an input
- Add REFRESH button to dashboard header that triggers lt.autoRefresh.now()
  for immediate manual refresh with timer restart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:16:18 -04:00
jared b03a9cfc8c Extract hardcoded rgba colors and inline styles to CSS classes
- Add .inline-error and .inline-warning utility classes to dashboard.css
  with correctly-matched terminal palette rgba values (replaces off-palette
  rgba(231,76,60,0.1) and rgba(241,196,15,0.1))
- Add .key-generated-alert class for the new API key display frame
- Add base .dependency-item, .dependency-group h4, .dependency-item a,
  .dependency-title, .btn-small overrides to ticket.css
- Remove all inline styles from the dependency list template in ticket.js
  — layout, colors, and sizing now come from CSS classes
- Update CreateTicketView.php and ApiKeysView.php to use the new classes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:08:52 -04:00
jared d44a530018 Extend lt.time.ago() to ticket view, replace showToast with lt.toast
- Add data-ts attributes to TicketView.php: ticket created/updated
  header, comment dates (inner span to preserve edited indicator),
  and all activity timeline dates
- Add initRelativeTimes() to ticket.js using lt.time.ago(); runs on
  DOMContentLoaded and every 60s to keep relative times current
- Attachment dates now use lt.time.ago() with full date in title attr
  and ts-cell span for periodic refresh
- Replace all 11 showToast() calls in ticket.js with lt.toast.* directly,
  removing reliance on the backwards-compat shim for these paths
- Add span.ts-cell and td.ts-cell CSS to both dashboard.css and ticket.css:
  dotted underline + cursor:help signals the title tooltip is available

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:03:34 -04:00
jared 3c3b9d0a61 Integrate lt.time.ago() for dashboard timestamps, update README
- Add data-ts attributes to table and card view date cells so JS can
  convert them to relative time ("2h ago") while keeping the full date
  in the title attribute for hover tooltips
- Add initRelativeTimes() in dashboard.js using lt.time.ago(); runs on
  DOMContentLoaded and refreshes every 60s so times stay current
- Fix table sort for date columns to read data-ts attribute instead of
  text content (which is now relative and not sortable as a date)
- Update README: add base.css/base.js/utils.js to project structure,
  fix ascii-banner.js description, expand keyboard shortcuts table,
  add developer notes for lt.time and boot sequence behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:52:59 -04:00
jared 1046537429 Move ASCII banner into boot sequence, fix remaining UI issues
- Remove collapsible ASCII banner from dashboard (was cluttering the UI)
- Show ASCII banner in the boot overlay on first session visit, above
  the boot messages, with a 400ms pause before messages begin
- Add scroll fade indicator (green-tinted gradient edges) to .table-wrapper
  so users can see when the table is horizontally scrollable
- Fix null guards for tab switcher in ticket.js (tabEl, activeBtn)
- Fix Reset → RESET uppercase in AuditLogView and UserActivityView

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:41:57 -04:00
jared d8220da1e0 Make dashboard table horizontally scrollable at smaller screen widths
- Set .table-wrapper to overflow-x: auto with touch scrolling support
- Add min-width: 900px to table to trigger scroll before columns collapse
- Set .ascii-frame-outer overflow-x: visible to avoid clipping conflict

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:35:45 -04:00
jared 021c01b3d4 Polish: uppercase all admin view button text
- AuditLogView.php: FILTER, RESET
- UserActivityView.php: APPLY, RESET
- ApiKeysView.php: GENERATE KEY, COPY, REVOKE
- WorkflowDesignerView.php: + NEW TRANSITION, EDIT, DELETE, SAVE, CANCEL
- CustomFieldsView.php: + NEW FIELD, EDIT, DELETE, SAVE, CANCEL
- TemplatesView.php: + NEW TEMPLATE, EDIT, DELETE, SAVE, CANCEL
- RecurringTicketsView.php: + NEW RECURRING TICKET, EDIT, DISABLE/ENABLE, DELETE, SAVE, CANCEL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:27:18 -04:00
jared 22cab10d5d Polish: uppercase remaining modal and pagination button text
- DashboardView.php: settings modal SAVE PREFERENCES/CANCEL, advanced search SEARCH/RESET/CANCEL
- DashboardView.php: pagination prev/next add [ « ] and [ » ] brackets
- TicketView.php: settings modal SAVE PREFERENCES/CANCEL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:19:13 -04:00
jared f0d7b9aa61 Polish: uppercase all remaining mixed-case button text
- DashboardView.php: APPLY FILTERS, CLEAR ALL, SEARCH, CHANGE STATUS, ASSIGN, PRIORITY, CLEAR, EXPORT SELECTED
- CreateTicketView.php: CREATE TICKET, CANCEL
- ticket.js: SAVE, CANCEL, REMOVE, REPLY in dynamically-generated HTML templates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:14:41 -04:00
jared 3493ed78f8 Polish: uppercase button text, ASCII-safe stat icons and boot sequence
- TicketView.php: 'Edit Ticket' → 'EDIT TICKET'
- DashboardView.php: '+ New Ticket' → '+ NEW TICKET'
- DashboardView.php: stat-icon [ ✓ ] → [ OK ] (ASCII-safe)
- DashboardView.php: boot sequence '> SYSTEM READY ✓' → '> SYSTEM READY [OK]'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:10:38 -04:00
jared 90c5b3ff71 UI/UX polish: terminal design system alignment pass
Views:
- DashboardView.php: remove hardcoded [ ] from admin-badge button (CSS adds them)
- DashboardView.php: view toggle ≡/▦ → [ = ]/[ # ] (view-btn suppresses auto-brackets)
- DashboardView.php: clear-search ✗ → [ X ] (plain text, no auto-brackets on <a>)
- DashboardView.php: remove ↓ arrow emoji from export button text
- TicketView.php: tab labels → UPPERCASE (tab-btn CSS adds [ ] around text)
- TicketView.php: Edit Ticket/Clone/Add Comment/Add → title-case → UPPERCASE
- TicketView.php: reply button ↩ → [ << ] (comment-action-btn has no auto-brackets)

JavaScript:
- dashboard.js: modal/action button text all → UPPERCASE (CONFIRM/CANCEL/SAVE/ASSIGN/UPDATE/DELETE PERMANENTLY)
- dashboard.js: null guard in loadTemplate(), toggleSelectAll()
- ticket.js: null guards in addDependency(), handleFileUpload()

CSS:
- dashboard.css: z-index 1001/1002 magic numbers → var(--z-modal)/var(--z-popover)
- ticket.css: status-select hover/focus border rgba(white) → terminal palette

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:50:59 -04:00
jared 84bea80abd Fix PHP parse error and CSS/JS follow-on fixes
- DashboardView.php: fix PHP parse error on line 456/472/473/474 caused by
  escaped double-quotes {$row[\"key\"]} inside double-quoted echo strings;
  replaced with safe string concatenation . $row['key'] .
- ticket.css: fix status-select hover/focus border rgba(white) → terminal palette
- ticket.js: add null guards to addComment, togglePreview, updatePreview,
  toggleMarkdownMode, and addDependency element lookups

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:44:08 -04:00
jared 2f9af856dc Fix design system violations: replace off-brand colors with terminal palette
- dashboard.css: replace all hardcoded Tailwind hex colors (#2d3748, #1a202c,
  #e2e8f0, #4a5568, #007cba, #3b82f6 etc.) in dark-mode sections and component
  styles with terminal CSS variables (--bg-*, --text-*, --border-color,
  --terminal-green/amber)
- dashboard.css: fix card-priority colors white/black → var(--bg-primary)
- dashboard.css: fix card-assignee border-radius: 50% → 0 (no circles rule)
- dashboard.css: fix mobile bottom-sheet border-radius: 12px → 0
- dashboard.css: fix search-box focus border (#007cba → var(--terminal-green))
- dashboard.css: fix save-filter button blue (#3b82f6) → terminal green
- dashboard.css: fix search-results-info blue highlight → terminal green
- dashboard.css: fix btn-bulk/btn-secondary dark-mode bootstrap colors → terminal
- ticket.css: replace comprehensive dark-mode Tailwind hex block with CSS vars
- ticket.css: fix status-select white/black text → var(--bg-primary)
- ticket.css: fix status-select.status-resolved hardcoded #28a745 → var(--status-open)
- ticket.css: fix timeline dark-mode hardcoded colors → CSS vars
- ticket.css: fix .slider:before background white → var(--bg-primary)
- ticket.css: fix .btn-danger:hover color white → var(--bg-primary)
- ticket.css: fix visibility-groups-list label border-radius: 4px → 0
- ticket.css: add will-change: opacity to age-warning/age-critical animations
- views: bump CSS version strings to v=20260319c
- views/DashboardView.php: add aria-labels to card view quick action buttons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:37:19 -04:00
jared 27075a62ee Fix bracket buttons rendering below text + UI/security improvements
CSS fixes:
- Fix [ ] brackets appearing below button text by replacing display:inline-flex
  with display:inline-block + white-space:nowrap on .btn — removes cross-browser
  flex pseudo-element inconsistency as root cause
- Remove conflicting .btn::before ripple block (position:absolute was overriding
  bracket content positioning)
- Remove overflow:hidden from .btn which was clipping bracket content
- Fix body::after duplicate rule causing GPU layer blink (second position:fixed
  rule re-created compositor layer, overriding display:none suppression)
- Replace all transition:all with scoped property transitions in dashboard.css,
  ticket.css, base.css (prevents full CSS property evaluation on every hover)
- Convert pulse-warning/pulse-critical keyframes from box-shadow to opacity
  animation (GPU-composited, eliminates CPU repaints at 60fps)
- Fix mobile *::before/*::after blanket content:none rule — now targets only
  decorative frame glyphs, preserving button brackets and status indicators
- Remove --terminal-green-dim override that broke .lt-btn hover backgrounds

JS fixes:
- Fix all lt.lt.toast.* double-prefix instances in dashboard.js
- Add null guard before .appendChild() on bulkAssignUser select
- Replace all remaining emoji with terminal bracket notation (dashboard.js,
  ticket.js, markdown.js)
- Migrate all toast.*() shim calls to lt.toast.* across all JS files

View fixes:
- Remove hardcoded [ ] brackets from .btn buttons (CSS now adds them)
- Replace all emoji with terminal bracket notation in all views and admin views
- Add missing CSP nonces to AuditLogView.php and UserActivityView.php script tags
- Bump CSS version strings to ?v=20260319b for cache busting

Security fixes:
- update_ticket.php: add authorization check (non-admins can only edit their own
  or assigned tickets)
- add_comment.php: validate and cast ticket_id to integer with 400 response
- clone_ticket.php: fix unconditional session_start(), add ticket ID validation,
  add internal ticket access check
- bulk_operation.php: add HTTP 401/403 status codes on auth failures
- upload_attachment.php: fix missing $conn arg in AttachmentModel constructor
- assign_ticket.php: add ticket existence check and permission verification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:20:43 -04:00
jared dd8833ee2f Restore visual effects using GPU-safe techniques (no repaint triggers)
Rules: transform/opacity = GPU composited (fine). box-shadow/text-shadow on
hover = CPU repaint (removed). Static box-shadow/text-shadow = painted once (fine).

- Buttons (.btn, .btn-base, button, .btn-primary): add will-change:transform
  for pre-promotion, add transform:translateY(-1px) on hover (GPU, no repaint),
  scope transition to include transform, remove box-shadow/text-shadow from hover
- Stat cards: add will-change:transform, add transform:translateY(-2px) on hover
- Priority badges: replace filter:blur(6px) ::after pseudo-element (permanent GPU
  layer per badge, ~20 on screen at once) with static box-shadow:0 0 6px currentColor
  on the badge itself — painted once, never changes, zero compositor overhead
- Links: replace opacity-transition ::after underline (lazy GPU layer creation on
  hover) with text-decoration:underline on hover (pure CPU paint, no GPU layer)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 19:28:28 -04:00
jared ab3e77a9ba Fix blink root cause: eliminate position:fixed GPU compositing layers
Chrome promotes ALL position:fixed elements to GPU compositing layers for scroll
performance, regardless of whether they have animations. The body::before scanline
overlay (position:fixed, z-index:9999, full-viewport) and body::after watermark
(position:fixed) were both on GPU layers. Every CPU repaint from any hover state
change required a compositor re-blend pass → one-frame blink at compositor sync.

Fixes:
- Move scanlines from body::before (position:fixed) into body { background-image }
  — same visual, no separate element, no GPU layer promotion
- Set body::before { display:none } and body::after { display:none } in both
  dashboard.css and base.css
- Remove animation:matrix-rain from .stat-card:hover::before — background-position
  animation is not GPU-composited, caused CPU repaints every frame while hovered
  plus GPU texture uploads when animation started/stopped on cursor enter/exit
- Scope a { transition: all } → transition: color in base.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:23:30 -04:00
jared 68ff89b48c Fix persistent blink: scanline animation still active via base.css cascade
Root cause: removing 'animation' from dashboard.css body::before did NOT disable
the scanline — it just stopped overriding base.css which still had
'animation: scanline 8s linear infinite'. CSS cascade means the base.css value
remained active. Fixed by setting 'animation: none' explicitly in dashboard.css.

Also fix base.css (used by all pages including ticket page):
- Set body::before animation: none (removes GPU compositing layer from scanline)
- Change corner-pulse/subtle-pulse/pulse-glow/pulse-red keyframes from text-shadow
  and box-shadow animations to opacity (GPU composited, zero CPU repaint overhead)
- Change exec-running-pulse from box-shadow to opacity
- Remove box-shadow from .lt-table tr:hover, .lt-card:hover, .lt-stat-card:hover
- Remove text-shadow/box-shadow/transform from .lt-btn:hover and variants
- Remove text-shadow from a:hover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:12:44 -04:00
jared 328c103460 Fix ascii-frame-outer blink: eliminate all repaint-causing hover effects
- Change pulse-glow keyframes from text-shadow animation to opacity (GPU composited,
  eliminates 60fps CPU repaint that was the likely root cause of the persistent blink)
- Remove box-shadow from .quick-action-btn:hover; scope transition: all → background/color
- Remove box-shadow + background gradient + transform:translateY from .stat-card:hover;
  scope transition: all → border-color only
- Remove .stat-card::after transition and hover background change
- Remove duplicate .stat-card:hover transform:translateY block
- Remove box-shadow from .clear-search-btn:hover; scope transition: all → background/color
- Remove text-shadow from th transition (th:hover never changes text-shadow)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:06:39 -04:00
jared 21ef9154e9 Fix ascii-frame-outer blink: remove scanline GPU layer and remaining repaint triggers
- Remove body::before scanline animation (transform: translateY promoted it to a
  GPU compositing layer; CPU repaints from hover states required compositor re-blend,
  causing one-frame blink at compositor sync boundary)
- Remove text-shadow and transform: translateY(-2px) from .btn-primary:hover/.create-ticket:hover
- Scope .btn-primary transition from 'all' to specific composited properties
- Remove box-shadow: inset from .banner-toggle:hover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:57:47 -04:00
jared 4ecd72bc04 Strip all box-shadow/text-shadow from hover states inside ascii-frame-outer
These non-composited properties force CPU repaints of the surrounding
paint region (the section itself) every time hover enters or exits.
With the fixed overlay (body::before scanline), each such repaint
requires the compositor to re-blend the layer, visible as a blink.

Removed from dashboard.css:
- btn/button/btn-base:hover: box-shadow + text-shadow
- th:hover: text-shadow
- ticket-link:hover: text-shadow
- pagination button:hover: box-shadow + transform + transition:all
- ticket-card-row:hover: box-shadow + transition:all -> background only
- .btn ripple rule: transition:all -> specific properties
- ascii-frame-outer: removed will-change/translateZ (GPU upload worse)

Removed from ticket.css:
- metadata-select:hover: box-shadow; transition:all -> border-color
- comment:hover: box-shadow
- btn:hover: box-shadow
- mention:hover: text-shadow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:50:06 -04:00
jared 368ad9b48e Promote ascii-frame-outer to GPU layer to stop hover blink
The body::before scanline overlay (position:fixed, z-index:9999) requires
the compositor to re-blend over the section every time a CPU repaint
happens inside it. Hover state entry/exit triggers these repaints, causing
a visible blink as the compositor flushes.

Fixes:
- Add will-change:transform + transform:translateZ(0) to ascii-frame-outer
  to promote it to its own GPU compositing layer, isolating its repaints
  from the scanline compositing pass
- Convert corner-pulse and subtle-pulse from text-shadow (CPU repaint)
  to opacity (GPU composited) to eliminate continuous repaint pressure
  inside the section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:35:38 -04:00
jared 3497c4cb47 Fix ascii-frame-outer blink: remove compositor layer thrashing on hover
Four root causes removed:
- body { transition: all } — forced browser to check all CSS properties
  on every hover event across the entire page
- a:not(.btn)::after underline: width+box-shadow transition replaced with
  opacity transition — width repaints paint layer, box-shadow forced parent
  section repaint; opacity is GPU-composited and doesn't repaint ancestors
- .ticket-link:hover { transform: translateX } — created/destroyed GPU
  compositor layer on every ticket ID hover; removed, scoped transition
  to specific non-layout properties
- .btn:hover { transform: translateY } in ticket.css — same layer issue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:25:18 -04:00
jared e756f8e0bb Fix ascii-frame-outer blink caused by JS/CSS hover conflict
JS mouseenter/mouseleave handlers were setting row.style.backgroundColor
inline, fighting with the CSS tr:hover rule. On mouseleave both fired
simultaneously causing a double repaint / blink. Removed the redundant
JS handlers — the CSS tr:hover transition already handles this cleanly.

Also removed body flicker animation from base.css (was still present
after being removed from dashboard.css).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:14:05 -04:00
jared fea7575ac8 Fix dashboard blink when cursor leaves ticket table section
- Remove pulse-glow-box animation and translateY from button:hover
  (infinite animation stopping abruptly caused a flash on mouse-leave)
- Scope button transition from 'all' to specific visual properties
  (prevents transform/layout changes from triggering on hover exit)
- Scope th transition from 'all' to background-color + text-shadow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:10:27 -04:00
jared 6fbba3939f Remove page-blinking CSS animations and old deploy script
- Remove .ascii-frame-outer:hover flicker animation (caused article to
  shake/blink every time cursor entered the ticket container)
- Remove body flicker animation (caused full page blink every 30s)
- Remove deploy.sh (deployment now handled by Gitea CI/CD)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:04:03 -04:00
jared f3c15e2582 Fix table row blink when cursor leaves the table
transition:all was firing on every row simultaneously when the cursor
left the table. Scoped it to background-color only. Also removed the
inset box-shadow from tr:hover which forced repaint layer thrashing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 10:55:50 -04:00
jared 51fa5a8a3c Add lt.keys.initDefaults() to audit log and user activity views
Ensures ESC/Ctrl+K/? keyboard shortcuts work consistently on all admin pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:04:50 -04:00
jared 4a838b68ca Move base.js/base.css into assets to fix auth proxy 404
/web_template/ path was being intercepted by the auth proxy at
t.lotusguild.org returning HTML instead of the actual files. Moving
base.js and base.css into /assets/js/ and /assets/css/ where static
assets are already served correctly. Updated all 10 view files and
deploy.sh accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:44:46 -04:00
jared 5545328e53 Fix deploy.sh to also sync web_template to server
base.js and base.css were returning 404 because /var/www/html/web_template
did not exist on the server. Now rsyncs /root/code/web_template/ to
/var/www/html/web_template/ before deploying the app.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:36:55 -04:00
jared 8bb43c14db Guard lt.* calls when base.js unavailable to prevent crash
Wraps all lt.keys.initDefaults() calls in `if (window.lt)` guards across
6 view files. Adds `if (!window.lt) return` bail-out in keyboard-shortcuts.js
and `if (window.lt)` guard in settings.js DOMContentLoaded handler.

This prevents TypeError crashes when /web_template/base.js returns 404,
which was causing the admin menu click delegation to never register.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:34:59 -04:00
jared 92544d60ce Fix lt-modal-overlay not hidden without base.css
Add lt-modal-overlay, lt-modal, lt-btn fallback styles to dashboard.css
so modals are properly hidden (display:none) and styled even when
/web_template/base.css is not yet served. Mirrors the rules from base.css.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:29:20 -04:00
jared 89a685a502 Integrate web_template design system and fix security/quality issues
Security fixes:
- Add HTTP method validation to delete_comment.php (block CSRF via GET)
- Remove $_GET fallback in comment deletion (was CSRF bypass vector)
- Guard session_start() with session_status() check across API files
- Escape json_encode() data attributes with htmlspecialchars in views
- Escape inline APP_TIMEZONE config values in DashboardView/TicketView
- Validate timezone param against DateTimeZone::listIdentifiers() in index.php
- Remove Database::escape() (was using real_escape_string, not safe)
- Fix AttachmentModel hardcoded connection; inject via constructor

Backend fixes:
- Fix CommentModel bind_param type for ticket_id (s→i)
- Fix buildCommentThread orphan parent guard
- Fix StatsModel JOIN→LEFT JOIN so unassigned tickets aren't excluded
- Add ticket ID validation in BulkOperationsModel before implode()
- Add duplicate key retry in TicketModel::createTicket() for race conditions
- Wrap SavedFiltersModel default filter changes in transactions
- Add null result guards in WorkflowModel query methods

Frontend JS:
- Rewrite toast.js as lt.toast shim (base.js dependency)
- Delegate escapeHtml() to lt.escHtml()
- Rewrite keyboard-shortcuts.js using lt.keys.on()
- Migrate settings.js to lt.api.* and lt.modal.open/close()
- Migrate advanced-search.js to lt.api.* and lt.modal.open/close()
- Migrate dashboard.js fetch calls to lt.api.*; update all dynamic
  modals (bulk ops, quick actions, confirm/input) to lt-modal structure
- Migrate ticket.js fetchMentionUsers to lt.api.get()
- Remove console.log/error/warn calls from JS files

Views:
- Add /web_template/base.css and base.js to all 10 view files
- Call lt.keys.initDefaults() in DashboardView, TicketView, admin views
- Migrate all modal HTML from settings-modal/settings-content to
  lt-modal-overlay/lt-modal/lt-modal-header/lt-modal-body/lt-modal-footer
- Replace style="display:none" with aria-hidden="true" on all modals
- Replace modal open/close style.display with lt.modal.open/close()
- Update modal buttons to lt-btn lt-btn-primary/lt-btn-ghost classes
- Remove manual ESC keydown handlers (replaced by lt.keys.initDefaults)
- Fix unescaped timezone values in TicketView inline script

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:22:24 -04:00
jared d204756cfe Remove Claude.md (merged into README) and remove aesthetic_diff.md reference
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 21:49:51 -04:00
jared a34ca51223 Apply LotusGuild design system convergence (aesthetic_diff.md)
- §10: Filter sidebar labels color green→amber with glow-amber,
  matching unified amber-for-labels convention from base.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 21:40:43 -04:00
jared f59913910f Replace Discord webhook notifications with Matrix (hookshot)
- Add helpers/NotificationHelper.php: shared Matrix webhook sender
  that reads MATRIX_WEBHOOK_URL and MATRIX_NOTIFY_USERS from config
- Remove sendDiscordWebhook() from TicketController; call
  NotificationHelper::sendTicketNotification() instead
- Replace 60-line Discord embed block in create_ticket_api.php
  with a single NotificationHelper call
- config/config.php: DISCORD_WEBHOOK_URL → MATRIX_WEBHOOK_URL +
  new MATRIX_NOTIFY_USERS key (comma-separated Matrix user IDs)
- .env.example: updated env var names and comments

Payload sent to hookshot includes notify_users array so the
JS transform can build proper @mention links for each user.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 19:17:46 -05:00
jared 13f0fab138 Fix avg resolution time using dedicated closed_at column
The dashboard's "Avg Resolution" stat was using updated_at, which gets
overwritten on any post-close edit (title change, comment, etc.),
inflating the metric. Also fixes "Closed Today" count for the same reason.

- Add closed_at TIMESTAMP column to tickets table
- Set closed_at on close, preserve on re-edit, clear on reopen
- Update StatsModel queries to use closed_at instead of updated_at
- Add migration script with audit log backfill for existing tickets

Run: php scripts/add_closed_at_column.php

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:59:35 -05:00
jared bcc163bc77 Audit fixes: security, dead code removal, API consolidation, JS dedup
Security:
- Fix IDOR in delete/update comment (add ticket visibility check)
- XSS defense-in-depth in DashboardView active filters
- Replace innerHTML with DOM construction in toast.js
- Remove redundant real_escape_string in check_duplicates
- Add rate limiting to get_template, download_attachment, audit_log,
  saved_filters, user_preferences endpoints

Bug fixes:
- Session timeout now reads from config instead of hardcoded 18000
- TicketController uses $GLOBALS['config'] instead of duplicate .env parsing
- Add DISCORD_WEBHOOK_URL to centralized config
- Cleanup script uses hashmap for O(1) ticket ID lookups

Dead code removal (~100 lines):
- Remove dead getTicketComments() from TicketModel (wrong bind_param type)
- Remove dead getCategories()/getTypes() from DashboardController
- Remove ~80 lines dead Discord webhook code from update_ticket API

Consolidation:
- Create api/bootstrap.php for shared API setup (auth, CSRF, rate limit)
- Convert 6 API endpoints to use bootstrap
- Extract escapeHtml/getTicketIdFromUrl into shared utils.js
- Batch save for user preferences (1 request instead of 7)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:50:06 -05:00
jared 15063838bd Fix Discord webhook showing localhost instead of APP_DOMAIN
create_ticket_api.php was not including config/config.php, so
$GLOBALS['config'] was empty when UrlHelper::ticketUrl() checked
for APP_DOMAIN, causing it to fall back to localhost.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 12:31:49 -05:00
jared 019eaf8980 Add assignment dropdown on ticket creation and fix Discord webhook URLs
- Add APP_DOMAIN config for correct Discord webhook ticket links
- Add "Assign To" dropdown on create ticket form
- Update TicketModel.createTicket() to support assigned_to field
- Update documentation for APP_DOMAIN requirement

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 10:24:00 -05:00
jared e8b2f670b9 Fix mobile bottom nav consistency and ticket view width
Mobile bottom nav:
- Added nav-label class to all text labels in JS
- Fixed icon sizing (20px fixed height)
- Fixed label sizing (10px for all)
- Equal width columns (25% each)
- Changed gear emoji from ⚙️ to ⚙ for consistency

Ticket view mobile:
- Removed all borders from ticket container
- Removed decorative corners on mobile
- Reduced nested padding significantly
- ascii-frame-inner now 0.75rem padding (was 1rem)
- Nested ascii-frame-inner only 0.5rem
- detail-group full-width has no padding
- Content goes edge-to-edge

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 11:59:31 -05:00
jared b0ffc2cdc2 Fix mobile bottom nav sizing and improve ticket view on mobile
Mobile bottom nav:
- Consistent sizing for icons (1.1rem) and text (0.7rem)
- Added .nav-label class for text labels
- Increased height to 64px for better touch targets
- Added active state styling

Ticket view mobile improvements:
- Full width container (removed margins, no side borders)
- Wider tab content areas with proper padding
- Tabs now fill available width
- Active tab has bottom border indicator
- Description textarea full width with proper sizing
- Markdown preview with better font sizing
- Improved comment form styling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 11:51:02 -05:00
jared cbce4b5fac Massively improve responsive layouts for smaller screens
Added breakpoints:
- 900-1399px: Tablet with sidebar, card layout
- 600-899px: Small tablet, compact cards with actions
- 480-599px: Large phone, hidden sidebar, mobile filter toggle
- Below 768px: Full mobile optimization

Card improvements:
- Better touch targets (48px buttons)
- Clearer visual hierarchy
- Active states for touch feedback
- Priority border indicators
- Clean meta information layout

Mobile improvements:
- Removed card gaps for cleaner list appearance
- Larger fonts for readability
- Better spacing and padding
- Touch-friendly action buttons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 11:40:57 -05:00
jared 23da1ef421 Fix: ticket cards now visible below 1400px - move hide rule to media query 2026-01-31 11:38:18 -05:00
jared 79706f790d Switch to responsive card layout below 1400px for dashboard
Major improvements:
- Replace table with card-based layout below 1400px width
- Cards show ticket ID, title, category, assignee, status, and actions
- Priority indicated by left border color
- Fully responsive from 1400px down to mobile

Mobile improvements (768px and below):
- Cards stack vertically with touch-friendly sizing
- Action buttons are full-width with 44px touch targets
- Meta info displayed in a clean row format
- Removed old table-based mobile styles

Sidebar collapse improvements:
- Collapsed state now truly saves space (0 width, no gap)
- Expand button is compact vertical text

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 11:33:40 -05:00
jared 99a96544cf Improve sidebar collapse and add responsive table handling
Sidebar collapse improvements:
- Remove gap when sidebar is collapsed
- Hide sidebar content completely when collapsed
- Make expand button compact (vertical text)

Table responsive improvements:
- Add breakpoints for 1200-1599px and 1000-1199px ranges
- Hide less important columns progressively as screen shrinks
- Ensure table doesn't overflow container with overflow-x: auto
- Reduce padding and font size on smaller screens

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 11:28:31 -05:00
jared df367b9914 Remove tbody tr::before pseudo-element causing column misalignment
The ::before element on tbody tr was creating a blank column space
that didn't affect the thead, causing visual misalignment.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 11:06:13 -05:00
jared 44221b858c Fix thead/tbody alignment by adding matching border to header
The tbody first column had a 6px left border for priority indicator,
but the thead first column didn't have this border, causing misalignment.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 10:44:11 -05:00
jared 712e9b70ce Fix table header alignment by removing prompt from checkbox column
The '> ' prefix was being added to the checkbox header column,
causing misalignment with the data rows.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 10:40:37 -05:00
jared 7a6e7ea2b0 Remove scrollbars: content wraps and boxes expand to fit
- Change overflow-x from auto to visible in table wrapper
- Allow text wrapping in table cells instead of ellipsis truncation
- Remove min-width constraints that forced horizontal scrolling
- Change textarea white-space from pre to pre-wrap
- Remove fixed min-height on ticket container and description
- Update mobile styles to wrap content instead of scroll

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 10:36:56 -05:00
jared 2657e86d24 Enhance CSS/HTML with semantic elements, utility classes, and breakpoints
- Move inline styles to CSS classes in ticket.css and dashboard.css
- Add intermediate responsive breakpoints (600px, 900px, 1200px)
- Convert HTML to semantic elements (header, section, article)
- Add ARIA attributes for modals and navigation
- Add utility classes for text styling and spacing
- Update cache-busting version numbers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 10:29:20 -05:00
jared 73162d9a9b Add comprehensive accessibility improvements
HTML Accessibility:
- Add ARIA roles to tab navigation (role="tablist", role="tab", role="tabpanel")
- Add aria-selected to tab buttons with JS toggle
- Add aria-controls and aria-labelledby for tab/panel relationships
- Add aria-label to emoji icon buttons (settings, reply, edit, delete)
- Add aria-pressed to view toggle buttons
- Add labels for form inputs (comment textarea, dependency inputs, file input)
- Add .sr-only utility class for screen-reader-only content

CSS Accessibility:
- Add .sr-only class (visually hidden, accessible to screen readers)

JavaScript:
- Update showTab() to toggle aria-selected on tab buttons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 00:15:11 -05:00
jared 2ba3d40b3b CSS improvements and fixes
- Add missing CSS variables (--terminal-green-dim, --terminal-red)
- Add global box-sizing: border-box for consistent layouts
- Fix duplicate keyframe animations (blink-cursor, pulse-glow)
- Replace hardcoded hex colors with CSS variables
- Fix textarea width calculations (remove calc workarounds)
- Add responsive thread depth for mobile
- Add accessibility improvements:
  - Visible focus outlines for keyboard navigation
  - prefers-reduced-motion support
- Fix duplicate transition property in .tab-btn
- Update slider checked color to use terminal-green

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 00:02:17 -05:00
jared 3ceea77fe1 Fix reply: dynamically add to DOM instead of page reload 2026-01-30 23:54:42 -05:00
jared 651c8115f6 Fix CSP violation by using event delegation for reply form buttons 2026-01-30 23:51:29 -05:00
jared 6dff92db45 Add debugging for reply button click issue 2026-01-30 23:49:21 -05:00
jared a8738fdf57 Add comment threading and fix fetch authentication
- Add comment threading/reply functionality with nested display
  - Database migration for parent_comment_id and thread_depth columns
  - Recursive comment rendering with depth-based indentation
  - Reply form with inline UI and smooth animations
  - Thread collapse/expand capability
  - Max thread depth of 3 levels

- Fix 401 authentication errors on API calls
  - Add credentials: 'same-origin' to all fetch calls
  - Affects settings.js, ticket.js, dashboard.js, advanced-search.js
  - Ensures session cookies are sent with requests

- Enhanced comment styling
  - Thread connector lines for visual hierarchy
  - Reply button on comments (up to depth 3)
  - Quote block styling for replies

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:43:36 -05:00
jared 1c1eb19876 Add UI enhancements and new features
Keyboard Navigation:
- Add J/K keys for Gmail-style ticket list navigation
- Add N key for new ticket, C for comment focus
- Add G then D for go to dashboard (vim-style)
- Add 1-4 number keys for quick status changes on ticket page
- Add Enter to open selected ticket
- Update keyboard help modal with all new shortcuts

Ticket Age Indicator:
- Show "Last activity: X days ago" on ticket view
- Visual warning (yellow pulse) for tickets idle >5 days
- Critical warning (red pulse) for tickets idle >10 days

Ticket Clone Feature:
- Add "Clone" button on ticket view
- Creates copy with [CLONE] prefix in title
- Preserves description, priority, category, type, visibility
- Automatically creates "relates_to" dependency to original

Active Filter Badges:
- Show visual badges above ticket table for active filters
- Click X on badge to remove individual filter
- "Clear All" button to reset all filters
- Color-coded by filter type (status, priority, search)

Visual Enhancements:
- Add keyboard-selected row highlighting for J/K navigation
- Smooth animations for filter badges

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:21:36 -05:00
jared 9b40a714ed Fix critical bugs breaking ticket page and settings modal
- Fix fatal PHP error in UserModel::getAllGroups() - typo 'setCache'
  should be 'setCached', was causing ticket page to fail to render
- Fix settings.js null reference errors when timezone element missing
  on ticket page (only exists on dashboard)
- Fix ESC key detection for settings modal (checked 'block' but modal
  uses 'flex' display)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:10:30 -05:00
jared ed9c2a39d1 Fix error message disclosure in API endpoints
Replace exception getMessage() exposure with generic error messages
to prevent internal information disclosure. Errors are now logged
with full details while clients receive sanitized responses.

Affected endpoints:
- add_comment, update_comment, delete_comment
- update_ticket, export_tickets
- generate_api_key, revoke_api_key
- manage_templates, manage_workflows, manage_recurring
- custom_fields, get_users

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:56:29 -05:00
jared 5b2a2c271e Add security logging, domain validation, and output helpers
- Add authentication failure logging to AuthMiddleware (session expiry,
  access denied, unauthenticated access attempts)
- Add UrlHelper for secure URL generation with host validation against
  configurable ALLOWED_HOSTS whitelist
- Add OutputHelper with consistent XSS-safe escaping functions (h, attr,
  json, url, css, truncate, date, cssClass)
- Add validation to AuditLogModel query parameters (pagination limits,
  date format validation, action/entity type validation, IP sanitization)
- Add APP_DOMAIN and ALLOWED_HOSTS configuration options

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:51:16 -05:00
jared 44f2c21f2d Add query optimization and reliability improvements
- Consolidate StatsModel queries from 12 to 3 using conditional aggregation
- Add input validation to DashboardController (sort columns, dates, priorities)
- Combine getCategories/getTypes into single query
- Add transaction support to BulkOperationsModel with atomic mode option
- Add depth limit (20) to dependency cycle detection to prevent DoS
- Add caching to UserModel.getAllGroups() with 5-minute TTL
- Improve ticket ID generation with 50 attempts, exponential backoff, and fallback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:31:46 -05:00
jared 7575d6a277 Add performance, security, and reliability improvements
- Consolidate all 20 API files to use centralized Database helper
- Add optimistic locking to ticket updates to prevent concurrent conflicts
- Add caching to StatsModel (60s TTL) for dashboard performance
- Add health check endpoint (api/health.php) for monitoring
- Improve rate limit cleanup with cron script and efficient DirectoryIterator
- Enable rate limit response headers (X-RateLimit-*)
- Add audit logging for workflow transitions
- Log Discord webhook failures instead of silencing
- Fix visibility check on export_tickets.php
- Add database migration system with performance indexes
- Fix cron recurring tickets to use assignTicket method

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 14:39:13 -05:00
jared c3f7593f3c Harden CSP by removing unsafe-inline for scripts
Refactored all inline event handlers (onclick, onchange, onsubmit) to use
addEventListener with data-action attributes and event delegation pattern.

Changes:
- views/*.php: Replaced inline handlers with data-action attributes
- views/admin/*.php: Same refactoring for all admin views
- assets/js/dashboard.js: Added event delegation for bulk/quick action modals
- assets/js/ticket.js: Added event delegation for dynamic elements
- assets/js/markdown.js: Refactored toolbar button handlers
- assets/js/keyboard-shortcuts.js: Refactored modal close button
- SecurityHeadersMiddleware.php: Enabled strict CSP with nonces

The CSP now uses script-src 'self' 'nonce-{nonce}' instead of 'unsafe-inline',
significantly improving XSS protection.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:15:55 -05:00
jared 37be81b3e2 Add PHP 7.4+ type hints to helpers, models, and middleware
Added strict typing with parameter types, return types, and property
types across all core classes:
- helpers: Database, ErrorHandler, CacheHelper
- models: TicketModel, UserModel, WorkflowModel, TemplateModel, UserPreferencesModel
- middleware: RateLimitMiddleware, CsrfMiddleware, SecurityHeadersMiddleware

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 11:04:36 -05:00
jared 8a8b1b0258 Add centralized error handler
- Add ErrorHandler class for consistent error handling and logging
- Provides methods for common error responses (401, 403, 404, 422, 500)
- Includes error logging to temp directory
- Update get_template.php to use ErrorHandler (example migration)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:55:15 -05:00
jared d2a8c73e2c Add caching layer and database helper
- Add CacheHelper for file-based caching with TTL support
- Add Database helper for centralized connection management
- Update WorkflowModel to cache status transitions (10 min TTL)
- Update UserPreferencesModel to cache user prefs (5 min TTL)
- Update manage_workflows.php to clear cache on changes
- Update get_users.php to use Database helper (example migration)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:53:26 -05:00
jared 1101558fca Remove nonce from CSP to allow unsafe-inline to work
Browsers ignore 'unsafe-inline' when a nonce is present. Reverting to
unsafe-inline only until all inline handlers are refactored.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:46:06 -05:00
jared 55209e0b05 Fix CSP blocking inline handlers - add unsafe-inline fallback
- Refactored TicketView.php to use event listeners instead of onclick
- Added unsafe-inline to CSP as fallback for legacy handlers in other views
- TODO: Complete refactoring of DashboardView and admin views

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:42:09 -05:00
jared 674a427edb Fix duplicate PHP tag in TicketView causing 500 error
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:31:57 -05:00
jared fa40010287 Security hardening and performance improvements
- Add visibility check to attachment downloads (prevents unauthorized access)
- Fix ticket ID collision with uniqueness verification loop
- Harden CSP: replace unsafe-inline with nonce-based script execution
- Add IP-based rate limiting (supplements session-based)
- Add visibility checks to bulk operations
- Validate internal visibility requires groups
- Optimize user activity query (JOINs vs subqueries)
- Update documentation with design decisions and security info

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:27:15 -05:00
jared a08390a500 added close modal keybinds for admin menu 2026-01-26 11:41:33 -05:00
jared 80a61fcd31 Remove fixed min-width from setting-row labels and inputs
- Removed min-width: 180px from .setting-row label
- Changed min-width: 200px to min-width: 0 for form inputs
- Labels now size to content, inputs fill remaining space
- Updated cache version to 20260126c

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 11:34:15 -05:00
jared 2be85b6f58 Fix admin form layout - add compact setting-row class for grid layouts
- Added .setting-row-compact class for stacked label/input layout
- Updated TemplatesView.php grid to use compact rows (3 columns)
- Updated RecurringTicketsView.php grid to use compact rows (2 columns)
- Removed inline style="width: 100%" (handled by CSS now)
- Labels now stack above inputs in grid context for clarity
- Updated cache version to 20260126b

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 11:30:46 -05:00
jared b1013392e6 Fix template priority field name and improve admin form styling
Template fixes:
- Fixed column name mismatch: use 'default_priority' instead of 'priority'
- Updated manage_templates.php API INSERT and UPDATE queries
- Updated TemplatesView.php to use correct field name in PHP and JS

CSS improvements for .setting-row:
- Better flexbox layout with flex-wrap for responsiveness
- Proper styling for inputs, selects, and textareas in setting rows
- Labels now align to top (better for textareas)
- Added focus states with amber glow effect
- Improved checkbox styling within setting rows
- Better mobile responsive behavior (stacked layout)
- Updated cache version to 20260126a

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 11:21:29 -05:00
jared 8b89114607 Unify Discord webhook notifications between API and manual ticket creation
- Standardized embed format across both ticket creation paths
- Added consistent priority colors (P1-P5) with distinct hex values
- Added priority labels (e.g., "P1 - Critical" instead of just "1")
- Added Source field showing hostname extracted from ticket title
- Added Status field to both webhook formats
- Added footer distinguishing "Automated Alert" vs "Manual Entry"
- Added timestamp to API endpoint webhooks
- Added error logging for failed webhook calls
- Added timeout (10s) to API endpoint curl calls
- Added null check for webhook URL in API endpoint

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 11:11:40 -05:00
jared ee796dce91 fix: Handle missing updated_at column in comment updates
Check if updated_at column exists before using it in UPDATE query.
This allows comment editing to work before migration script is run.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 17:04:13 -05:00
jared 98db586bcf feat: Comment edit/delete, auto-link URLs, markdown tables, mobile fixes
- Add comment edit/delete functionality (owner or admin can modify)
- Add edit/delete buttons to comments in TicketView
- Create update_comment.php and delete_comment.php API endpoints
- Add updateComment() and deleteComment() methods to CommentModel
- Show "(edited)" indicator on modified comments
- Add migration script for updated_at column

- Auto-link URLs in plain text comments (non-markdown)
- Add markdown table support with proper HTML rendering
- Preserve code blocks during markdown parsing

- Fix mobile UI elements showing on desktop (add display:none defaults)
- Add mobile styles for CreateTicketView form elements
- Stack status-priority-row on mobile devices

- Update cache busters to v20260124e
- Update Claude.md and README.md documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 16:59:29 -05:00
jared 7ecb593c0f fix: Mobile sidebar and ticket page improvements
Dashboard sidebar fixes:
- Added proper styling for sidebar interior on mobile
- Filter groups have touch-friendly labels (44px height)
- Larger checkboxes (22px)
- Full-width apply/clear buttons
- Border separators between filter groups

Ticket page fixes:
- Metadata fields stack vertically on mobile
- Assignment dropdown full-width
- All selects have 48px height and 16px font
- Better spacing throughout
- Sticky header

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 11:12:43 -05:00
jared d073add6a6 feat: Complete mobile UI overhaul
Major mobile improvements:
- Sticky header with simplified controls
- Slide-out filter sidebar with overlay
- Bottom navigation bar (Home, Filter, New, Settings)
- Stacked toolbar layout
- Full-width modals sliding up from bottom
- Admin dropdown as bottom sheet
- Horizontal scrolling table with touch support
- 44px minimum touch targets throughout
- iOS zoom prevention on inputs
- Landscape mode optimizations

CSS changes:
- Rewrote all mobile styles with correct class names
- Added mobile bottom nav styles
- Fixed toolbar-left, toolbar-center, toolbar-right
- Fixed user-header-left, user-header-right

JS changes:
- initMobileSidebar now creates bottom nav
- Removed style.display = 'none' (CSS handles visibility)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 10:48:32 -05:00
jared efa1b81a62 chore: Update cache version to 20260124 for mobile CSS/JS changes
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 10:38:56 -05:00
jared 7465fb6fc4 feat: Comprehensive mobile UI improvements
Dashboard mobile changes:
- Sidebar becomes slide-out drawer with overlay
- Added mobile filter toggle button
- Table wrapped for horizontal scroll
- Stats grid: 2 columns on tablet, 1 on phone
- Larger touch targets (44px minimum)
- Full-width modals with better spacing
- Admin dropdown slides up from bottom
- Fixed bulk action bar at bottom

Ticket page mobile changes:
- Stack metadata vertically
- Full-width buttons and inputs
- Scrollable tabs
- Better comment form layout
- Improved timeline readability

General:
- Prevent iOS zoom with 16px input font
- Touch-friendly spacing throughout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 22:10:29 -05:00
jared ee317d6662 fix: Keyboard shortcuts for ? key and ESC modal closing
- Fix ? shortcut: removed incorrect !e.shiftKey condition
- ESC now closes all modal types (overlay, settings, advanced search)
- Replace toast-based help with proper styled modal
- ESC also blurs focused inputs before canceling edit mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 22:04:39 -05:00
jared 11a593a7dd refactor: Code cleanup and documentation updates
Bug fixes:
- Fix ticket ID extraction using URLSearchParams instead of split()
- Add error handling for query result in get_users.php
- Make Discord webhook URLs dynamic (use HTTP_HOST)

Code cleanup:
- Remove debug console.log statements from dashboard.js and ticket.js
- Add getTicketIdFromUrl() helper function to both JS files

Documentation:
- Update Claude.md: fix web server (nginx not Apache), add new notes
- Update README.md: add keyboard shortcuts, update setup instructions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 22:01:20 -05:00
jared 6e569c8918 fix: Remove redundant session_start from get_users.php
RateLimitMiddleware already starts the session.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:28:39 -05:00
jared 9360e38fbb fix: Use utf8mb4_general_ci collation for ticket_dependencies table
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:22:56 -05:00
jared 5c22526c08 fix: Add missing API routes to index.php
Added routes for all API endpoints that were missing:
- ticket_dependencies, upload_attachment, delete_attachment
- get_users, assign_ticket, get_template
- bulk_operation, export_tickets
- generate_api_key, revoke_api_key
- manage_templates, manage_workflows, manage_recurring
- check_duplicates

This fixes the 500/404 errors on Dependencies tab and other API calls.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:19:24 -05:00
jared 6d03f9c89b fix: Session auth, sidebar toggle, and dependencies table
- Change session.cookie_samesite from Strict to Lax for Authelia compatibility
- Redesign sidebar toggle with separate collapse/expand buttons
- Add script to create missing ticket_dependencies table
- Add .env.example template
- Add check for missing .env with helpful error message

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:16:29 -05:00
jared 380b0e1adf fix: Sidebar toggle positioning and documentation updates
- Fix collapsible sidebar toggle button positioning (moved outside sidebar)
- Toggle button now stays visible when sidebar is collapsed
- Update cache busting version
- Update Claude.md with new features documentation
- Update README.md with new features documentation
- Remove migrations folder (no longer needed)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:39:55 -05:00
jared b8a987e4c6 fix: Cache busting and visibility group editing UI
- Add cache busting query params to JS/CSS files (v=20260123)
- Add visibility group selection UI for editing existing tickets
- Add toggleVisibilityGroupsEdit() and getSelectedVisibilityGroups() functions
- Fix visibility data being saved when editing tickets
- Pass $conn to views for UserModel access

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:23:19 -05:00
jared e86a5de3fd feat: Add 9 new features for enhanced UX and security
Quick Wins:
- Feature 1: Ticket linking in comments (#123456789 auto-links)
- Feature 6: Checkbox click area fix (click anywhere in cell)
- Feature 7: User groups display in settings modal

UI Enhancements:
- Feature 4: Collapsible sidebar with localStorage persistence
- Feature 5: Inline ticket preview popup on hover (300ms delay)
- Feature 2: Mobile responsive improvements (44px touch targets, iOS zoom fix)

Major Features:
- Feature 3: Kanban card view with status columns (toggle with localStorage)
- Feature 9: API key generation admin panel (/admin/api-keys)
- Feature 8: Ticket visibility levels (public/internal/confidential)

New files:
- views/admin/ApiKeysView.php
- api/generate_api_key.php
- api/revoke_api_key.php
- migrations/008_ticket_visibility.sql

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:01:50 -05:00
jared c32e9c871b feat: Add timezone setting in preferences + clickable logo
- Add timezone dropdown to settings modal with common timezones
- Save/load timezone preference per user
- Apply user's timezone preference after authentication
- Override system default with user preference if set
- Make dashboard logo clickable (returns to default filters)
- Show current timezone in settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:54:04 -05:00
jared 8b4ef2a7f5 feat: Add timezone support with EST default
- Add TIMEZONE config option (default: America/New_York)
- Set PHP default timezone from config
- Add timezone offset and abbreviation for JavaScript
- Update stat card filters to use server timezone
- Add timezone config to Dashboard and Ticket views

Timezone can be changed via TIMEZONE env variable.
All dates now consistent with server timezone (EST by default).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:48:25 -05:00
jared 2c35ccc199 fix: Add table alias to COUNT query for advanced filters
The WHERE conditions use 't.' prefix but the COUNT query was missing
the table alias, causing 500 errors when using priority_max, assigned_to,
or date filters.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:36:42 -05:00
jared 0046721fde feat: Add admin navigation, fix modals, clickable stats, update docs
- Add admin dropdown menu in dashboard header with links to all admin pages
- Fix template modal: larger size (800px), responsive grid, type/priority dropdowns
- Fix recurring tickets modal: add Type and Assign To fields, larger size
- Make dashboard stat cards clickable for quick filtering
- Fix user-activity query (remove is_active requirement)
- Add table existence check in ticket_dependencies API
- Fix table overflow on dashboard
- Update Claude.md and README.md with current project status
- Remove migrations directory (all migrations completed)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:11:49 -05:00
jared 08d6808bc3 Update README.md and add debug error handlers
- Completely rewrote README with all new features and admin routes
- Cleaned up remaining migration files
- Added detailed PHP error/exception handlers to dependencies API
  to help debug the 500 error

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 17:25:54 -05:00
jared 7462d7c509 fix: Add error handling to dependencies + cleanup migrations
- Add detailed error handling in DependencyModel (throw exceptions on failure)
- Add try-catch in ticket_dependencies.php to catch query errors
- Remove all old migrations (001-014) that have already been run
- Keep only new feature migrations (015-018) for reference

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 17:07:54 -05:00
jared 2ce4a14201 fix: Use LEFT JOIN in DependencyModel queries
Makes queries more defensive - returns dependencies even if the
linked ticket was deleted.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 17:02:50 -05:00
jared 92f936e1be fix: Fix upload_attachment.php AuditLogModel call
- Fix AuditLogModel instantiation with proper $conn parameter
- Fix log() call parameter order (details should be array, not ipAddress)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 17:01:42 -05:00
jared ebf318f8af fix: Fix delete_attachment.php AuditLogModel calls
- Add session status check
- Remove broken AuditLogModel call without $conn in CSRF check
- Fix AuditLogModel instantiation with proper $conn parameter
- Fix log() call to pass array instead of JSON string for details

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 17:00:54 -05:00
jared 10d5075f2d fix: Fix duplicate session_start() in API files
- Add session status check before starting session
- Add error reporting settings for debugging
- Prevents potential session conflicts with RateLimitMiddleware

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 16:53:00 -05:00
jared 7dffd8ed35 fix: Remove broken AuditLogModel call in upload_attachment.php
The AuditLogModel was being instantiated without required $conn parameter
when logging CSRF failures, causing a 500 error.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 16:51:26 -05:00
jared 591fad52cc Add deployment scripts and preserve uploads folder
- Add scripts/deploy.sh for safe deployment with uploads preservation
- Add scripts/cleanup_orphan_uploads.php to remove orphaned files
- Add .gitkeep to uploads folder
- Update .gitignore to exclude uploaded files but keep folder structure

The deploy script now:
- Backs up and restores .env file
- Backs up and restores uploads folder contents
- Runs database migrations automatically

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 15:27:05 -05:00
jared bc6a5cecf8 fix: Resolve multiple UI and API bugs
- Remove is_active filter from get_users.php (column doesn't exist)
- Fix ticket ID validation regex in upload_attachment.php (9-digit format)
- Fix createSettingsModal reference to use openSettingsModal from settings.js
- Add error handling for dependencies tab to prevent infinite loading
- Add try-catch wrapper to ticket_dependencies.php API
- Make export dropdown visible only when tickets are selected
- Export only selected tickets instead of all filtered tickets

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 15:16:14 -05:00
jared be505b7312 Implement comprehensive improvement plan (Phases 1-6)
Security (Phase 1-2):
- Add SecurityHeadersMiddleware with CSP, X-Frame-Options, etc.
- Add RateLimitMiddleware for API rate limiting
- Add security event logging to AuditLogModel
- Add ResponseHelper for standardized API responses
- Update config.php with security constants

Database (Phase 3):
- Add migration 014 for additional indexes
- Add migration 015 for ticket dependencies
- Add migration 016 for ticket attachments
- Add migration 017 for recurring tickets
- Add migration 018 for custom fields

Features (Phase 4-5):
- Add ticket dependencies with DependencyModel and API
- Add duplicate detection with check_duplicates API
- Add file attachments with AttachmentModel and upload/download APIs
- Add @mentions with autocomplete and highlighting
- Add quick actions on dashboard rows

Collaboration (Phase 5):
- Add mention extraction in CommentModel
- Add mention autocomplete dropdown in ticket.js
- Add mention highlighting CSS styles

Admin & Export (Phase 6):
- Add StatsModel for dashboard widgets
- Add dashboard stats cards (open, critical, unassigned, etc.)
- Add CSV/JSON export via export_tickets API
- Add rich text editor toolbar in markdown.js
- Add RecurringTicketModel with cron job
- Add CustomFieldModel for per-category fields
- Add admin views: RecurringTickets, CustomFields, Workflow,
  Templates, AuditLog, UserActivity
- Add admin APIs: manage_workflows, manage_templates,
  manage_recurring, custom_fields, get_users
- Add admin routes in index.php

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 09:55:01 -05:00
jared 8c7211d311 Add Ceph cluster-wide ticket deduplication support
Update generateTicketHash() to exclude hostname from hash for
cluster-wide Ceph issues, enabling proper deduplication across
all nodes in the cluster.

Cluster-wide issues detected by:
- [cluster-wide] tag in title
- HEALTH_ERR or HEALTH_WARN in title
- "cluster usage" in title

This prevents all nodes from creating duplicate tickets for the
same cluster-wide issue (e.g., Ceph HEALTH_WARN).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 15:53:45 -05:00
jared 496e8d6c21 fix: Use parseMarkdown instead of marked.parse for comment preview
Fixed markdown preview for comments by replacing marked.parse() calls
with parseMarkdown() function. The application uses a custom markdown
parser (markdown.js), not the marked.js library.

Changes:
- togglePreview(): Use parseMarkdown() instead of marked.parse()
- updatePreview(): Use parseMarkdown() instead of marked.parse()

Resolves issue where markdown preview didn't work for comments but
worked after posting.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-13 15:16:25 -05:00
jared ee69b9094b Update Claude.md 2026-01-12 17:01:38 -05:00
jared bb4b1400f2 Update README.md 2026-01-12 17:00:33 -05:00
jared 1b66663307 fix: Pass selectedOption parameter to performStatusChange function
Fixed scope issue where selectedOption variable was not accessible in
performStatusChange(). Updated function signature to accept selectedOption
as a parameter and updated both call sites to pass it.

Resolves error: "selectedOption is not defined" when changing ticket status.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 17:08:11 -05:00
jared 63dc2d6314 fix: Correct function closure in ticket.js breaking tab navigation
Fixed syntax error from previous commit where updateTicketStatus()
function had incorrect closing. Changed `});` to `}` at line 434.

This was preventing showTab() and other functions from loading,
breaking the Description/Comments/Activity tab navigation.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 17:04:21 -05:00
126 changed files with 28785 additions and 9491 deletions
+39
View File
@@ -0,0 +1,39 @@
# Tinker Tickets Environment Configuration
# Copy this file to .env and fill in your values
# Database Configuration
DB_HOST=10.10.10.50
DB_USER=tinkertickets
DB_PASS=your_password_here
DB_NAME=ticketing_system
# Matrix Webhook (optional - for notifications via matrix-hookshot)
# Set to your hookshot generic webhook URL, e.g.:
# https://matrix.lotusguild.org/webhook/<uuid>
MATRIX_WEBHOOK_URL=
# Matrix users to @mention on every new ticket (comma-separated Matrix user IDs)
# e.g. @jared:matrix.lotusguild.org,@alice:matrix.lotusguild.org
MATRIX_NOTIFY_USERS=
# Application Domain (required for Matrix webhook ticket links)
# Set this to your public domain (e.g., t.lotusguild.org)
APP_DOMAIN=
# Allowed Hosts for HTTP_HOST validation (comma-separated)
# Include all domains that can access this application
ALLOWED_HOSTS=localhost,127.0.0.1
# Timezone (default: America/New_York)
TIMEZONE=America/New_York
# LDAP / lldap (for user avatar lookups)
LDAP_ENABLED=true
LDAP_HOST=10.10.10.39
LDAP_PORT=3890
LDAP_BIND_DN=uid=tinker-tickets,ou=people,dc=example,dc=com
LDAP_BIND_PW=
LDAP_BASE_DN=dc=example,dc=com
LDAP_USER_BASE=ou=people,dc=example,dc=com
# How long to cache avatar images locally (seconds, default 3600)
AVATAR_CACHE_TTL=3600
+25
View File
@@ -0,0 +1,25 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 2021,
"sourceType": "script"
},
"globals": {
"lt": "readonly",
"module": "writable"
},
"rules": {
"no-undef": "off",
"no-unused-vars": "warn",
"no-empty": "warn",
"no-inner-declarations": "warn",
"no-useless-escape": "warn",
"no-regex-spaces": "warn",
"semi": ["error", "always"],
"eqeqeq": "warn"
}
}
+93
View File
@@ -0,0 +1,93 @@
name: Lint
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
php-lint:
name: PHP (phpcs PSR-12)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install PHP and phpcs
run: |
apt-get update -qq
apt-get install -y -qq php-cli php-xml
curl -sL https://squizlabs.github.io/PHP_CodeSniffer/phpcs.phar -o /usr/local/bin/phpcs
chmod +x /usr/local/bin/phpcs
- name: Run phpcs
run: phpcs --standard=.phpcs.xml .
js-lint:
name: JS (eslint)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install ESLint
run: npm install --save-dev eslint@8
- name: Run ESLint
run: npx eslint assets/js/
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: [php-lint, js-lint]
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/development')
permissions:
contents: write
steps:
- name: Trigger webhook
env:
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
GIT_REF: ${{ github.ref }}
run: |
if [ "$GIT_REF" = "refs/heads/main" ]; then
HOOK_ID="tinker-deploy"
else
HOOK_ID="tinker-beta-deploy"
fi
PAYLOAD="{\"ref\":\"${GIT_REF}\"}"
SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
curl -sf --connect-timeout 10 \
-X POST \
-H "Content-Type: application/json" \
-H "X-Gitea-Signature: ${SIG}" \
-d "$PAYLOAD" \
"http://10.10.10.45:9000/hooks/${HOOK_ID}"
- name: Tag deployed commit
if: github.ref == 'refs/heads/main'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="deploy-$(date -u +%Y.%m.%d)-${{ github.run_number }}"
curl -sf -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"target\":\"${{ github.sha }}\",\"message\":\"Deployed to production\"}" \
"https://code.lotusguild.org/api/v1/repos/${{ github.repository }}/tags"
notify-failure:
name: Notify on failure
runs-on: ubuntu-latest
needs: [php-lint, js-lint]
if: failure() && github.event_name == 'push'
steps:
- name: Send Matrix alert
env:
MATRIX_WEBHOOK_URL: ${{ secrets.MATRIX_WEBHOOK_URL }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.ref_name }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if [ -z "$MATRIX_WEBHOOK_URL" ] || [ "$MATRIX_WEBHOOK_URL" = "CONFIGURE_ME" ]; then exit 0; fi
curl -sf -X POST "$MATRIX_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d "{\"text\":\"CI FAILED: ${REPO} @ ${BRANCH} — ${RUN_URL}\"}"
+30
View File
@@ -0,0 +1,30 @@
name: Security
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
schedule:
- cron: '0 6 * * 1'
jobs:
semgrep:
name: PHP Security (semgrep)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install semgrep
run: |
apt-get update -qq
apt-get install -y -qq python3 python3-pip
pip3 install semgrep
- name: Run semgrep
run: |
semgrep --config=p/php --config=p/owasp-top-ten --error \
--exclude-rule=php.lang.security.injection.echoed-request.echoed-request \
--exclude-rule=php.lang.security.injection.tainted-filename.tainted-filename \
--exclude-rule=php.lang.security.injection.tainted-callable.tainted-callable \
.
+8 -1
View File
@@ -1,2 +1,9 @@
.env .env
debug.log debug.log
.claude
settings.local.json
# Upload files (keep folder structure, ignore actual uploads)
uploads/*
!uploads/.gitkeep
!uploads/.htaccess
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0"?>
<ruleset name="TinkerTickets">
<description>PSR-12 with project-specific exclusions</description>
<file>.</file>
<exclude-pattern>*/uploads/*</exclude-pattern>
<exclude-pattern>*/migrations/*</exclude-pattern>
<exclude-pattern>*/.gitea/*</exclude-pattern>
<arg name="extensions" value="php"/>
<arg name="colors"/>
<arg value="sp"/>
<rule ref="PSR12">
<!-- Codebase does not use namespaces -->
<exclude name="PSR1.Classes.ClassDeclaration.MissingNamespace"/>
<!-- Files mix includes and class definitions by design -->
<exclude name="PSR1.Files.SideEffects.FoundWithSymbols"/>
<!-- View files contain long HTML strings — not worth wrapping -->
<exclude name="Generic.Files.LineLength"/>
</rule>
</ruleset>
-727
View File
@@ -1,727 +0,0 @@
# Tinker Tickets - Project Documentation for AI Assistants
## Project Status (January 2026)
**Current Phase**: All 5 core features implemented and deployed. Ready for ANSI Art redesign.
**Recent Completion**:
- ✅ Activity Timeline (Feature 1)
- ✅ Ticket Assignment (Feature 2)
- ✅ Status Transitions with Workflows (Feature 3)
- ✅ Ticket Templates (Feature 4)
- ✅ Bulk Actions - Admin Only (Feature 5)
**Next Priority**: 🎨 ANSI Art Redesign (major visual overhaul)
## Project Overview
Tinker Tickets is a feature-rich, self-hosted ticket management system built for managing data center infrastructure issues. It features SSO integration with Authelia/LLDAP, workflow management, Discord notifications, and a comprehensive web interface.
**Tech Stack:**
- Backend: PHP 7.4+ with MySQLi
- Frontend: Vanilla JavaScript, CSS3
- Database: MariaDB on separate LXC (10.10.10.50)
- Web Server: Apache on production (10.10.10.45)
- Authentication: Authelia SSO with LLDAP backend
- External Libraries: marked.js (Markdown rendering)
**Production Environment:**
- **Primary URL**: http://t.lotusguild.org
- **Web Server**: Apache at 10.10.10.45 (`/root/code/tinker_tickets`)
- **Database**: MariaDB at 10.10.10.50 (`ticketing_system` database)
- **Authentication**: Authelia provides SSO via headers
## Architecture
### MVC Pattern
```
Controllers → Models → Database
Views
```
### Project Structure (Updated)
```
/tinker_tickets/
├── api/ # API endpoints
│ ├── add_comment.php # POST: Add comment
│ ├── assign_ticket.php # POST: Assign ticket to user (NEW)
│ ├── bulk_operation.php # POST: Bulk operations - admin only (NEW)
│ ├── get_template.php # GET: Fetch ticket template (NEW)
│ ├── get_users.php # GET: Get user list (NEW)
│ └── update_ticket.php # POST: Update ticket (workflow validation)
├── assets/
│ ├── css/
│ │ ├── dashboard.css # Shared + dashboard + bulk actions
│ │ └── ticket.css # Ticket + timeline + dark mode fixes
│ ├── js/
│ │ ├── dashboard.js # Dashboard + hamburger + bulk actions + templates
│ │ └── ticket.js # Ticket + comments + status updates + assignment
│ └── images/
│ └── favicon.png
├── config/
│ └── config.php # Config + .env loading
├── controllers/ # MVC Controllers
│ ├── DashboardController.php # Dashboard with assigned_to column
│ └── TicketController.php # Ticket CRUD + timeline + templates
├── models/ # Data models
│ ├── AuditLogModel.php # Audit logging + timeline
│ ├── BulkOperationsModel.php # Bulk operations tracking (NEW)
│ ├── CommentModel.php # Comment data access
│ ├── TemplateModel.php # Ticket templates (NEW)
│ ├── TicketModel.php # Ticket CRUD + assignment
│ ├── UserModel.php # User management (NEW)
│ └── WorkflowModel.php # Status transition workflows (NEW)
├── views/ # PHP templates
│ ├── CreateTicketView.php # Ticket creation with templates
│ ├── DashboardView.php # Dashboard with bulk actions + assigned column
│ └── TicketView.php # Ticket view with timeline + assignment
├── migrations/ # Database migrations
│ ├── 001_initial_schema.sql
│ ├── 007_add_ticket_assignment.sql # Ticket assignment
│ ├── 008_add_status_workflows.sql # Workflow rules
│ ├── 009_add_ticket_templates.sql # Ticket templates
│ ├── 010_add_bulk_operations.sql # Bulk operations
│ └── 011_remove_view_tracking.sql # Remove view audit logs
├── .env # Environment variables (GITIGNORED)
├── Claude.md # This file
├── README.md # User documentation
├── index.php # Dashboard entry point
└── ticket.php # Ticket view/create router
```
## Database Schema (Updated)
**Database**: `ticketing_system` at 10.10.10.50
**User**: `tinkertickets`
**Connection**: All APIs create their own connections via config.php
### Core Tables
#### `tickets` Table (Updated)
```sql
CREATE TABLE tickets (
ticket_id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(50) DEFAULT 'Open',
priority INT DEFAULT 4,
category VARCHAR(50) DEFAULT 'General',
type VARCHAR(50) DEFAULT 'Issue',
created_by INT, -- User who created
updated_by INT, -- User who last updated
assigned_to INT, -- User assigned to (NEW)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(user_id),
FOREIGN KEY (updated_by) REFERENCES users(user_id),
FOREIGN KEY (assigned_to) REFERENCES users(user_id) ON DELETE SET NULL,
INDEX idx_status (status),
INDEX idx_assigned_to (assigned_to)
) ENGINE=InnoDB;
```
#### `users` Table (SSO Integration)
```sql
CREATE TABLE users (
user_id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
display_name VARCHAR(255),
email VARCHAR(255),
is_admin BOOLEAN DEFAULT FALSE,
last_login TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
```
#### `comments` Table
```sql
CREATE TABLE comments (
comment_id INT AUTO_INCREMENT PRIMARY KEY,
ticket_id INT NOT NULL,
user_id INT,
comment_text TEXT NOT NULL,
markdown_enabled BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ticket_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(user_id),
INDEX idx_ticket_id (ticket_id)
) ENGINE=InnoDB;
```
#### `audit_log` Table (Activity Timeline)
```sql
CREATE TABLE audit_log (
log_id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
action_type VARCHAR(50) NOT NULL, -- 'create', 'update', 'comment', 'assign', etc.
entity_type VARCHAR(50) NOT NULL, -- 'ticket', 'comment'
entity_id INT NOT NULL, -- ticket_id or comment_id
details JSON, -- JSON details of what changed
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(user_id),
INDEX idx_entity (entity_type, entity_id),
INDEX idx_user (user_id),
INDEX idx_action (action_type)
) ENGINE=InnoDB;
```
#### `status_transitions` Table (Workflow Rules)
```sql
CREATE TABLE status_transitions (
transition_id INT AUTO_INCREMENT PRIMARY KEY,
from_status VARCHAR(50) NOT NULL,
to_status VARCHAR(50) NOT NULL,
requires_comment BOOLEAN DEFAULT FALSE, -- Transition requires comment
requires_admin BOOLEAN DEFAULT FALSE, -- Transition requires admin
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_transition (from_status, to_status),
INDEX idx_from_status (from_status)
) ENGINE=InnoDB;
```
Default transitions:
```sql
-- Open → In Progress, Closed, Resolved
-- In Progress → Open, Closed, Resolved
-- Resolved → Closed, In Progress
-- Closed → Open, In Progress (requires comment)
```
#### `ticket_templates` Table
```sql
CREATE TABLE ticket_templates (
template_id INT AUTO_INCREMENT PRIMARY KEY,
template_name VARCHAR(100) NOT NULL,
title_template VARCHAR(255) NOT NULL,
description_template TEXT NOT NULL,
category VARCHAR(50),
type VARCHAR(50),
default_priority INT DEFAULT 4,
created_by INT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(user_id),
INDEX idx_template_name (template_name)
) ENGINE=InnoDB;
```
Default templates: Hardware Failure, Software Installation, Network Issue, Maintenance Request
#### `bulk_operations` Table
```sql
CREATE TABLE bulk_operations (
operation_id INT AUTO_INCREMENT PRIMARY KEY,
operation_type VARCHAR(50) NOT NULL, -- 'bulk_close', 'bulk_assign', 'bulk_priority'
ticket_ids TEXT NOT NULL, -- Comma-separated ticket IDs
performed_by INT NOT NULL,
parameters JSON, -- Operation parameters
status VARCHAR(20) DEFAULT 'pending',
total_tickets INT,
processed_tickets INT DEFAULT 0,
failed_tickets INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP NULL,
FOREIGN KEY (performed_by) REFERENCES users(user_id),
INDEX idx_performed_by (performed_by),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB;
```
## API Endpoints (Updated)
### Authentication
All API endpoints check: `$_SESSION['user']['user_id']` for authentication.
Admin-only endpoints check: `$_SESSION['user']['is_admin']`.
### POST `/api/update_ticket.php`
Updates ticket with workflow validation.
**Request:**
```json
{
"ticket_id": 123,
"status": "In Progress", // Validated against workflow rules
"priority": 2,
"title": "Updated title",
"description": "...",
"category": "Software",
"type": "Task"
}
```
**Response:**
```json
{
"success": true,
"status": "In Progress",
"priority": 2,
"message": "Ticket updated successfully"
}
```
**Features:**
- Workflow validation via WorkflowModel
- Partial updates (only send changed fields)
- User tracking (updated_by)
- Discord webhook notifications
- Audit logging
### POST `/api/assign_ticket.php` (NEW)
Assigns ticket to a user.
**Request:**
```json
{
"ticket_id": 123,
"assigned_to": 5 // user_id, or null to unassign
}
```
**Response:**
```json
{
"success": true
}
```
### GET `/api/get_users.php` (NEW)
Returns list of all users for assignment dropdowns.
**Response:**
```json
{
"success": true,
"users": [
{
"user_id": 1,
"username": "jared",
"display_name": "Jared Vititoe",
"is_admin": true
}
]
}
```
### GET `/api/get_template.php?template_id=1` (NEW)
Fetches a ticket template.
**Response:**
```json
{
"success": true,
"template": {
"template_id": 1,
"template_name": "Hardware Failure",
"title_template": "Hardware Failure: [Device Name]",
"description_template": "Device: \nIssue: \n...",
"category": "Hardware",
"type": "Problem",
"default_priority": 2
}
}
```
### POST `/api/bulk_operation.php` (NEW - ADMIN ONLY)
Performs bulk operations on tickets.
**Request:**
```json
{
"operation_type": "bulk_close", // or 'bulk_assign', 'bulk_priority'
"ticket_ids": [123, 456, 789],
"parameters": { // For bulk_assign or bulk_priority
"assigned_to": 5, // For bulk_assign
"priority": 2 // For bulk_priority
}
}
```
**Response:**
```json
{
"success": true,
"operation_id": 42,
"processed": 3,
"failed": 0,
"message": "Bulk operation completed: 3 succeeded, 0 failed"
}
```
### POST `/api/add_comment.php`
Adds comment to ticket.
**Request:**
```json
{
"ticket_id": 123,
"comment_text": "Comment content",
"markdown_enabled": true
}
```
**Response:**
```json
{
"success": true,
"user_name": "Jared Vititoe",
"created_at": "Jan 01, 2026 12:00"
}
```
## Key Features Implementation
### Feature 1: Activity Timeline
**Location**: Ticket view → Activity tab
**Implementation**:
- `AuditLogModel->getTicketTimeline()` - Fetches all events for a ticket
- Shows: creates, updates, comments, assignments, status changes
- Displays: user, action, timestamp, details
- CSS: timeline-content boxes with icons
- Dark mode: Fully supported
**Code**: `views/TicketView.php:258-282`, `models/AuditLogModel.php:getTicketTimeline()`
### Feature 2: Ticket Assignment
**Location**: Ticket view → "Assigned to" dropdown, Dashboard → "Assigned To" column
**Implementation**:
- Database: `tickets.assigned_to` column
- Models: `TicketModel->assignTicket()`, `TicketModel->unassignTicket()`
- API: `api/assign_ticket.php`
- Dashboard: Shows assigned user in table
- Auto-saves on change
- Audit logged
**Code**: `views/TicketView.php:170-181`, `assets/js/ticket.js:handleAssignmentChange()`
### Feature 3: Status Transitions with Workflows
**Location**: Ticket view → Status dropdown (header)
**Implementation**:
- Database: `status_transitions` table defines allowed transitions
- Models: `WorkflowModel->isTransitionAllowed()`, `WorkflowModel->getAllowedTransitions()`
- Dropdown shows only valid transitions for current status
- Server-side validation prevents invalid changes
- Can require comments or admin privileges
- Removed from hamburger menu (was duplicate)
**Code**: `models/WorkflowModel.php`, `api/update_ticket.php:130-144`, `views/TicketView.php:185-198`
### Feature 4: Ticket Templates
**Location**: Create ticket page → Template selector
**Implementation**:
- Database: `ticket_templates` table
- Models: `TemplateModel->getAllTemplates()`, `TemplateModel->getTemplateById()`
- API: `api/get_template.php`
- JavaScript: `loadTemplate()` in dashboard.js
- Auto-fills: title, description, category, type, priority
- 4 default templates included
**Code**: `views/CreateTicketView.php:27-39`, `assets/js/dashboard.js:loadTemplate()`
### Feature 5: Bulk Actions (Admin Only)
**Location**: Dashboard → Checkboxes + Toolbar (admins only)
**Implementation**:
- Database: `bulk_operations` table tracks operations
- Models: `BulkOperationsModel->processBulkOperation()`
- API: `api/bulk_operation.php`
- UI: Toolbar appears when tickets selected
- Operations: Bulk close, bulk assign, bulk priority
- All operations audit logged
- Server-side admin validation
**Code**: `views/DashboardView.php:176-188`, `assets/js/dashboard.js:bulkClose()`, `models/BulkOperationsModel.php`
## Authentication & SSO Integration
### Authelia Integration
User information passed via HTTP headers:
- `Remote-User`: Username
- `Remote-Name`: Display name
- `Remote-Email`: Email
- `Remote-Groups`: Comma-separated groups
### Session Management
```php
$_SESSION['user'] = [
'user_id' => 123,
'username' => 'jared',
'display_name' => 'Jared Vititoe',
'email' => 'jared@lotusguild.org',
'is_admin' => true // true if 'admins' in Remote-Groups
];
```
### Admin Privileges
- Bulk operations (close, assign, priority)
- Future: Admin-only transitions
## Frontend Components (Updated)
### Dashboard (`DashboardView.php` + `dashboard.js`)
**Features**:
- Sortable columns including new "Assigned To" column
- Search (title, description, ticket_id, category, type)
- Status filtering (default: Open + In Progress)
- Pagination (configurable)
- Dark mode toggle
- **Bulk Actions Toolbar** (admin only):
- Checkboxes on each ticket
- "Select All" checkbox
- Bulk close, assign, priority buttons
- Shows count of selected tickets
**Hamburger Menu**:
- Category/Type filtering
- Apply/Clear filters
- No status field (removed)
### Ticket View (`TicketView.php` + `ticket.js`)
**Features**:
- **Tabbed Interface**: Description, Comments, Activity
- **Activity Timeline**: Complete audit trail with icons
- **Assignment Dropdown**: Assign to users
- **Status Dropdown**: Workflow-validated status changes (header)
- **Hamburger Menu**: Priority, Category, Type editing
- **Edit Button**: Title and description editing
- **Markdown Comments**: With live preview
- **Dark Mode**: Comprehensive support
**Visual Indicators**:
- Priority colors (P1=Red, P2=Orange, P3=Blue, P4=Green, P5=Gray)
- Status badges (Open=Green, In Progress=Yellow, Closed=Red, Resolved=Green)
- Priority border colors on ticket container
### Create Ticket (`CreateTicketView.php`)
**Features**:
- **Template Selector**: Quick-fill from templates
- Standard fields: title, description, status, priority, category, type
- Form validation
- Discord webhook on creation
## Dark Mode (Fixed)
### Comprehensive Dark Mode CSS
**Files**: `assets/css/ticket.css`, `assets/css/dashboard.css`
**Colors**:
```css
body.dark-mode {
--bg-primary: #1a202c; /* Main background */
--bg-secondary: #2d3748; /* Cards, inputs */
--bg-tertiary: #4a5568; /* Hover states */
--text-primary: #e2e8f0; /* Main text */
--text-secondary: #cbd5e0; /* Secondary text */
--text-muted: #a0aec0; /* Muted text */
--border-color: #4a5568; /* Borders */
}
```
**Fixed Elements**:
- Timeline boxes (background + text)
- Bulk actions toolbar
- Tables and table rows
- Input fields and textareas
- Dropdowns and selects
- Comment boxes
- Modal dialogs
- All text elements
**Important**: Used `!important` flags to override any conflicting styles.
## Configuration
### Environment Variables (`.env`)
```ini
DB_HOST=10.10.10.50
DB_USER=tinkertickets
DB_PASS=password
DB_NAME=ticketing_system
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
```
**CRITICAL**: `.env` is gitignored! Never commit this file.
### Apache Configuration
**Virtual Host**: Apache serving from `/root/code/tinker_tickets`
```apache
<VirtualHost *:80>
ServerName t.lotusguild.org
DocumentRoot /root/code/tinker_tickets
<Directory /root/code/tinker_tickets>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
RewriteEngine On
RewriteBase /
RewriteRule ^ticket/([0-9]+)$ ticket.php?id=$1 [L,QSA]
RewriteRule ^ticket/create$ ticket.php?action=create [L,QSA]
</Directory>
</VirtualHost>
```
## Deployment
### Git Auto-Deploy
**Repository**: https://code.lotusguild.org/LotusGuild/tinker_tickets
**Flow**:
1. Push to `main` branch
2. Auto-deploys to `/root/code/tinker_tickets` on 10.10.10.45
3. `.env` is preserved
4. Migrations must be run manually
### Running Migrations
```bash
cd /root/code/tinker_tickets/migrations
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 007_add_ticket_assignment.sql
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 008_add_status_workflows.sql
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 009_add_ticket_templates.sql
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 010_add_bulk_operations.sql
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 011_remove_view_tracking.sql
```
## Development Guidelines
### Code Style
- **PHP**: Tabs for indentation, prepared statements, `htmlspecialchars()` for output
- **JavaScript**: Vanilla JS, `fetch()` for AJAX, clear function names
- **CSS**: CSS variables for theming, mobile-responsive
- **Security**: No SQL injection, XSS prevention, session validation
### Error Handling
- APIs return JSON with `{success: bool, error: string}`
- Debug logging to `/tmp/api_debug.log` (update_ticket.php)
- User-friendly error messages
### Adding New Features
1. **Database**: Create migration in `migrations/`
2. **Model**: Add methods to relevant Model class
3. **API**: Create API endpoint in `api/` (with auth check)
4. **Controller**: Update controller to load data
5. **View**: Add UI elements
6. **JavaScript**: Add interactivity
7. **CSS**: Style for light + dark mode
8. **Test**: Test thoroughly before pushing
## ANSI Art Redesign (Next Priority)
### Goal
Transform Tinker Tickets into a retro terminal aesthetic using ANSI art and ASCII characters.
### Design Concept
- **Terminal-style borders**: Use box-drawing characters (┌─┐│└─┘)
- **Monospace fonts**: Courier New, Consolas, Monaco
- **ASCII art headers**: Stylized "TINKER TICKETS" banner
- **Retro color palette**: Green terminal, amber terminal, or custom
- **Template objects**: Reusable border/box components
### Implementation Approach
1. **CSS Variables**: Define ANSI color palette
2. **Border Components**: Create CSS classes for boxes with ASCII borders
3. **Typography**: Monospace fonts throughout
4. **Icons**: Replace emoji with ASCII art
5. **Dashboard**: Terminal-style table with borders
6. **Tickets**: Box-drawing characters for sections
7. **Forms**: Terminal-style input boxes
### Reference Colors (Classic Terminal)
```css
:root {
--ansi-black: #000000;
--ansi-green: #00ff00;
--ansi-amber: #ffb000;
--ansi-blue: #0000ff;
--ansi-cyan: #00ffff;
--ansi-white: #ffffff;
--ansi-bg: #000000;
}
```
### Example Box Template
```
┌─────────────────────────────┐
│ TICKET #123 │
├─────────────────────────────┤
│ Title: Hardware Failure │
│ Status: [OPEN] │
│ Priority: P1 (CRITICAL) │
└─────────────────────────────┘
```
## Debugging
### Common Issues
```bash
# API debug logs
tail -f /tmp/api_debug.log
# Database connection
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system
# JavaScript console
# Open browser DevTools (F12) → Console tab
# Check dark mode
# localStorage.getItem('theme')
```
### Known Behaviors
- Ticket viewing no longer tracked (011 migration removes view logs)
- Status can only be changed via header dropdown (removed from hamburger)
- Bulk actions only visible to admins
- Templates are optional when creating tickets
- Workflow validation prevents invalid status transitions
## Important Notes for AI Assistants
1. **All 5 features are complete and deployed**
2. **Dark mode is fixed** with comprehensive CSS
3. **Next priority is ANSI Art redesign** (major visual overhaul)
4. **Database at 10.10.10.50**, can't access directly from dev machine
5. **Auto-deploy is active**, test carefully before pushing
6. **Session format**: `$_SESSION['user']['user_id']` (not `$_SESSION['user_id']`)
7. **API auth**: Check `$_SESSION['user']['user_id']` exists
8. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
9. **Config path**: `config/config.php` (not `config/db.php`)
10. **Migrations**: Must be run manually on database server
## File Reference Quick Guide
| File | Purpose | Key Functions |
|------|---------|---------------|
| `index.php` | Dashboard router | Database connection, routing |
| `ticket.php` | Ticket router | View/create ticket routing |
| `api/update_ticket.php` | Update API | Workflow validation, partial updates |
| `api/assign_ticket.php` | Assignment API | Assign/unassign tickets |
| `api/bulk_operation.php` | Bulk ops API | Admin bulk operations |
| `api/get_template.php` | Template API | Fetch ticket templates |
| `api/get_users.php` | Users API | Get user list |
| `models/TicketModel.php` | Ticket data | CRUD, assignment, filtering |
| `models/WorkflowModel.php` | Workflow rules | Status transition validation |
| `models/AuditLogModel.php` | Audit logging | Timeline, activity tracking |
| `models/TemplateModel.php` | Templates | Template CRUD |
| `models/BulkOperationsModel.php` | Bulk ops | Process bulk operations |
| `controllers/DashboardController.php` | Dashboard logic | Pagination, filters, assigned column |
| `controllers/TicketController.php` | Ticket logic | CRUD, timeline, templates |
| `assets/js/dashboard.js` | Dashboard UI | Filters, bulk actions, templates |
| `assets/js/ticket.js` | Ticket UI | Status updates, assignment, comments |
| `assets/css/dashboard.css` | Dashboard styles | Layout, table, bulk toolbar, dark mode |
| `assets/css/ticket.css` | Ticket styles | Timeline, ticket view, dark mode |
## Repository & Contact
- **Gitea**: https://code.lotusguild.org/LotusGuild/tinker_tickets
- **Production**: http://t.lotusguild.org
- **Infrastructure**: LotusGuild data center management
+508 -199
View File
@@ -1,142 +1,476 @@
# Tinker Tickets # Tinker Tickets
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management. [![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)
## ✨ Core Features 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.
### 📊 Dashboard & Ticket Management **Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets)
- **Smart Dashboard**: Sortable columns, advanced filtering by status/priority/category/type **Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template) — shared CSS, JS, and layout patterns for all LotusGuild apps
## Styling & Layout
Tinker Tickets uses the **LotusGuild Terminal Design System**. For all styling, component, and layout documentation see:
- [`web_template/README.md`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/README.md) — full component reference, CSS variables, JS API
- [`web_template/base.css`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.css) — unified CSS (`.lt-*` classes)
- [`web_template/base.js`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.js) — `window.lt` utilities (toast, modal, CSRF, fetch helpers)
- [`web_template/php/layout.php`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/php/layout.php) — PHP base layout template
**Key conventions:**
- All `.lt-*` CSS classes come from `base.css` — do not duplicate them in `assets/css/`
- All `lt.*` JS utilities come from `base.js` — use `lt.toast`, `lt.modal`, `lt.api`, etc.
- CSP nonces: every `<script>` tag needs `nonce="<?php echo $nonce; ?>"`
- CSRF: inject `window.CSRF_TOKEN` via the nonce-protected inline script block; `lt.api.*` adds the header automatically
## Design Decisions
The following features are intentionally **not planned** for this system:
- **Email Integration**: Matrix (hookshot webhook) is the chosen external notification method
- **Time Tracking**: Out of scope for current requirements
- **OAuth2/External Identity Providers**: Authelia is the only approved SSO method
## Core Features
### Dashboard & Ticket Management
- **View Modes**: Toggle between Table view and Kanban card view (drag-and-drop status changes)
- **Right Drawer Preview**: Click any ticket title to open a quick-preview panel without navigating away
- **Stats Widgets**: Clickable cards for quick filtering (Open, Critical, Unassigned, Today's tickets) with live trend indicators
- **Charts**: Priority distribution donut, status breakdown donut, and category bar chart (Chart.js, CDN)
- **Team Workload**: Collapsible panel showing open ticket count per assignee with progress bars
- **Full-Text Search**: Search across tickets, descriptions, and metadata - **Full-Text Search**: Search across tickets, descriptions, and metadata
- **Ticket Assignment**: Assign tickets to specific users with "Assigned To" column - **Advanced Search**: Date ranges (Flatpickr), priority ranges, user filters
- **Priority Tracking**: P1 (Critical) to P5 (Minimal Impact) with color-coded indicators - **Saved Filters**: Save and recall filter presets; quick-switch pills above the table
- **Column Visibility**: Toggle which dashboard table columns are shown; persisted in localStorage
- **Ticket Assignment**: Assign tickets to specific users with typeahead search
- **Priority Tracking**: P1 (Critical) to P5 (Minimal Impact) with color-coded indicators and status dots
- **Custom Categories**: Hardware, Software, Network, Security, General - **Custom Categories**: Hardware, Software, Network, Security, General
- **Ticket Types**: Maintenance, Install, Task, Upgrade, Issue, Problem - **Ticket Types**: Maintenance, Install, Task, Upgrade, Issue, Problem
- **Skeleton Loaders**: Loading placeholders during filter changes and data refresh
- **Export**: Export filtered tickets to CSV or JSON format
- **Ticket Linking**: Reference other tickets in comments using `#123456789` format
### 🔄 Workflow Management ### Ticket Visibility Levels
- **Status Transitions**: Enforced workflow rules (Open → In Progress → Resolved → Closed) - **Public**: All authenticated users can view the ticket
- **Internal**: Only users in specified groups can view the ticket (at least one group required)
- **Confidential**: Only the creator, assignee, and admins can view the ticket
### Workflow Management
- **Status Transitions**: Enforced workflow rules (Open → Pending → In Progress → Closed)
- **Comment Requirements**: Transitions that require a comment open an inline modal before committing the change
- **Workflow Designer**: Visual admin UI at `/admin/workflow` to configure transitions
- **Workflow Validation**: Server-side validation prevents invalid status changes - **Workflow Validation**: Server-side validation prevents invalid status changes
- **Admin Controls**: Certain transitions can require admin privileges - **Admin Controls**: Certain transitions can require admin privileges
- **Comment Requirements**: Optional comment requirements for specific transitions
- **Activity Timeline**: Complete audit trail of all ticket changes
### 💬 Collaboration Features ### Collaboration Features
- **Markdown Comments**: Full Markdown support with live preview - **Markdown Comments**: Full Markdown support with live preview, toolbar, and table rendering
- **User Tracking**: Tracks who created, updated, and assigned tickets - **@Mentions**: Tag users in comments with `@` autocomplete (typeahead); triggers Matrix notification to mentioned user
- **Activity Timeline**: Shows all ticket events (creates, updates, assignments, comments) - **Comment Edit/Delete**: Comment owners and admins can edit or delete comments
- **Real-time Updates**: AJAX-powered updates without page refreshes - **Auto-linking**: URLs in comments are automatically converted to clickable links
- **File Attachments**: Upload files to tickets with drag-and-drop; image attachments display as thumbnails with lightbox zoom
- **Ticket Cloning**: Duplicate any ticket with a single click; auto-links as `relates_to`
- **Ticket Dependencies**: Link tickets as blocks / blocked-by / relates-to / duplicates
- **Duplicate Detection**: Similarity check on ticket title surfaces potential duplicates with one-click linking
- **Activity Timeline**: Full `lt-timeline` audit trail — color-coded by event type (status, comment, assign, attach)
- **Watcher Avatars**: Avatar group shows who is watching a ticket; tooltip lists all names
- **SLA Timer**: P1/P2 tickets display a live elapsed-time banner with progress bar (P1 = 8 h, P2 = 24 h, P3 = 72 h)
- **Priority Alert Banner**: P1 shows a sticky error banner; P2 shows a warning banner — dismissible per session
### 🎫 Ticket Templates ### Ticket Templates
- **Template Management**: Admin UI at `/admin/templates` to create/edit templates
- **Quick Creation**: Pre-configured templates for common issues - **Quick Creation**: Pre-configured templates for common issues
- **Default Templates**: Hardware Failure, Software Installation, Network Issue, Maintenance
- **Auto-fill**: Templates populate title, description, category, type, and priority - **Auto-fill**: Templates populate title, description, category, type, and priority
### 👥 User Management & Authentication ### Recurring Tickets
- **Scheduled Tickets**: Automatically create tickets on a schedule
- **Admin UI**: Manage at `/admin/recurring-tickets`
- **Flexible Scheduling**: Daily, weekly, or monthly recurrence
- **Cron Integration**: Run `cron/create_recurring_tickets.php` to process
### Custom Fields
- **Per-Category Fields**: Define custom fields for specific ticket categories
- **Admin UI**: Manage at `/admin/custom-fields`
- **Field Types**: Text, textarea, select, checkbox, date, number
- **Required Fields**: Mark fields as required for validation
### API Key Management
- **Admin UI**: Generate and manage API keys at `/admin/api-keys`
- **Bearer Token Auth**: Use API keys with `Authorization: Bearer YOUR_KEY` header
- **Expiration**: Optional expiration dates for keys
- **Revocation**: Revoke compromised keys instantly
### User Management & Authentication
- **SSO Integration**: Authelia authentication with LLDAP backend - **SSO Integration**: Authelia authentication with LLDAP backend
- **Role-Based Access**: Admin and standard user roles - **Role-Based Access**: Admin and standard user roles
- **User Display Names**: Support for display names and usernames - **User Groups**: Groups displayed in settings modal, used for visibility
- **Session Management**: Secure PHP session handling - **User Avatars**: JPEG avatars fetched from lldap via LDAP; cached locally (`/api/user_avatar.php`)
- **User Activity**: View per-user stats at `/admin/user-activity`
- **Session Management**: Secure PHP session handling with timeout
### Bulk Actions (Admin Only) ### Bulk Actions (Admin Only)
- **Bulk Close**: Close multiple tickets at once - **Bulk Close**: Close multiple tickets at once
- **Bulk Assign**: Assign multiple tickets to a user - **Bulk Assign**: Assign multiple tickets to a user (typeahead search)
- **Bulk Priority**: Change priority for multiple tickets - **Bulk Priority**: Change priority for multiple tickets
- **Operation Tracking**: All bulk operations logged in audit trail - **Bulk Status**: Change status for multiple tickets
- **Checkbox Click Area**: Click anywhere in the checkbox cell to toggle
### 🔔 Notifications ### In-App Notifications
- **Discord Integration**: Webhook notifications for ticket creation and updates - **Notification Bell**: Header bell icon with unread count badge; polls every 60 s
- **Rich Embeds**: Color-coded priority indicators and ticket links - **Notification Sources**: Ticket assigned to you, comment on your ticket, status change on watched ticket, @mention
- **Change Tracking**: Detailed notification of what changed - **Mark All Read**: Click the bell or "Mark all read" to clear the badge
- **Powered by audit_log**: No extra table — notifications are derived from existing audit trail
### 🎨 User Interface ### Matrix Notifications (hookshot)
- **Dark Mode**: Full dark mode support with proper contrast - **Ticket Created**: Fires when any ticket is created (manual or via API)
- **Responsive Design**: Works on desktop and mobile devices - **Status Changed**: Fires on every status transition
- **Clean Layout**: Modern, intuitive interface - **@Mentions**: Mentioned users receive a direct Matrix notification
- **Hamburger Menu**: Quick access to ticket actions (priority, category, type) - **Assignment**: Optional — set `MATRIX_NOTIFY_ASSIGNMENTS=1` to enable
- **Comments**: Optional — set `MATRIX_NOTIFY_COMMENTS=1` to enable
- **Watcher Alerts**: Watchers receive Matrix notifications on status changes (resolved via Synapse Admin API)
- **Rich Payloads**: JSON payloads sent to hookshot generic webhook; format ticket links using `APP_DOMAIN`
## 🏗️ Technical Architecture ### Command Palette (Ctrl+K)
- **Global Access**: Available on every page via `Ctrl+K` or `⌘K` button in header
- **Quick Navigation**: Dashboard, New Ticket, My Tickets, admin pages
- **Recent Tickets**: Last 5 viewed tickets (stored in localStorage)
- **Filter Shortcuts**: Apply common filters directly from palette
### Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Ctrl/Cmd + K` | Open command palette (global) |
| `Ctrl/Cmd + E` | Toggle edit mode (ticket page) |
| `Ctrl/Cmd + S` | Save changes (ticket page) |
| `N` | New ticket (dashboard) |
| `J` / `K` | Next / previous row (dashboard table) |
| `Enter` | Open selected ticket (dashboard) |
| `G` then `D` | Go to dashboard |
| `1``4` | Quick status change (ticket page) |
| `ESC` | Cancel edit / close modal |
| `?` | Show keyboard shortcuts help |
### Security Features
- **CSRF Protection**: Token-based protection with constant-time comparison; token rotated after each write
- **Rate Limiting**: Session-based AND IP-based rate limiting to prevent abuse
- **Security Headers**: CSP with nonces (no unsafe-inline), X-Frame-Options, X-Content-Type-Options
- **SQL Injection Prevention**: All queries use prepared statements with parameter binding
- **XSS Protection**: HTML escaped in markdown parser, CSP headers block inline scripts
- **Audit Logging**: Complete audit trail of all actions
- **Visibility Enforcement**: Access checks on ticket views, downloads, and bulk operations
- **Collision-Safe IDs**: Ticket IDs verified unique before creation
## Automated Ticket Creation (hwmonDaemon)
[hwmonDaemon](https://code.lotusguild.org/LotusGuild/hwmonDaemon) runs on all servers and creates tickets automatically for hardware/health issues. It calls the **standalone API endpoint** at the document root:
```
POST /create_ticket_api.php
Authorization: Bearer <api_key>
Content-Type: application/json
{
"title": "[hostname][auto][production][hardware][single-node] SMART issues on /dev/sda",
"description": "...",
"priority": "2",
"category": "Hardware",
"type": "Issue"
}
```
**Key behaviours:**
- Authenticated via `Authorization: Bearer` header — API key stored in `/etc/hwmonDaemon/.env`
- **Deduplication**: Generates a SHA-256 hash from the issue category, hostname, and device; rejects duplicate tickets within 24 hours
- Cluster-wide issues (Ceph health, etc.) deduplicate across all nodes (hostname excluded from hash)
- Matrix notification sent automatically after ticket creation
- API key must be generated at `/admin/api-keys`; the key goes in hwmonDaemon's `/etc/hwmonDaemon/.env` as `TICKET_API_KEY`
## Technical Architecture
### Backend ### Backend
- **Language**: PHP 7.4+ - **Language**: PHP 7.4+
- **Database**: MariaDB/MySQL - **Database**: MariaDB/MySQL
- **Architecture**: MVC pattern with models, views, controllers - **Architecture**: MVC pattern with models, views, controllers
- **ORM**: Custom database abstraction layer - **Authentication**: Authelia SSO with LLDAP backend
### Frontend ### Frontend
- **HTML5/CSS3**: Semantic markup with modern CSS - **HTML5/CSS3**: Semantic markup with retro terminal styling
- **JavaScript**: Vanilla JS with Fetch API for AJAX - **JavaScript**: Vanilla JS with Fetch API for AJAX
- **Markdown**: marked.js for Markdown rendering - **Markdown**: Custom markdown parser with toolbar
- **Icons**: Unicode emoji icons - **Terminal UI**: Box-drawing characters, monospace fonts, CRT effects
- **Mobile Responsive**: Touch-friendly controls, responsive layouts
- **Chart.js**: CDN-loaded on dashboard only — priority/status/category charts
- **Flatpickr**: CDN-loaded on dashboard only — date range filter pickers
### Database Schema ### Database Tables
- **tickets**: Core ticket data with user tracking
- **comments**: Markdown-supported comments | Table | Purpose |
- **users**: User accounts synced from LLDAP |-------|---------|
- **audit_log**: Complete audit trail with JSON details | `tickets` | Core ticket data with visibility |
- **status_transitions**: Workflow configuration | `ticket_comments` | Markdown-supported comments |
- **ticket_templates**: Reusable ticket templates | `ticket_attachments` | File attachment metadata |
- **bulk_operations**: Tracking for bulk admin operations | `ticket_dependencies` | Ticket relationships |
| `ticket_watchers` | Per-user ticket subscriptions |
| `users` | User accounts with groups |
| `user_preferences` | User settings (rows per page, notification opts, notif_last_seen) |
| `audit_log` | Complete audit trail (also powers in-app notifications) |
| `status_transitions` | Workflow configuration |
| `ticket_templates` | Reusable templates |
| `recurring_tickets` | Scheduled tickets |
| `custom_field_definitions` | Custom field schemas |
| `custom_field_values` | Custom field data |
| `saved_filters` | Saved filter combinations |
| `bulk_operations` | Bulk operation tracking |
| `api_keys` | API key storage with hashed keys |
#### `tickets` Table Key Columns
| Column | Type | Description |
|--------|------|-------------|
| `ticket_id` | varchar(9) | Unique 9-digit identifier |
| `visibility` | enum | `public`, `internal`, `confidential` |
| `visibility_groups` | varchar(500) | Comma-separated group names (for internal) |
| `created_by` | int | Foreign key to users |
| `assigned_to` | int | Foreign key to users (nullable) |
| `updated_by` | int | Foreign key to users |
| `priority` | int | 15 (1=Critical, 5=Minimal) |
| `status` | varchar(20) | Open, Pending, In Progress, Closed |
#### Indexed Columns (performance)
- `tickets`: `ticket_id` (unique), `status`, `priority`, `created_at`, `created_by`, `assigned_to`, `visibility`
- `audit_log`: `user_id`, `action_type`, `entity_type`, `created_at`
### API Endpoints ### API Endpoints
- `/api/update_ticket.php` - Update ticket with workflow validation
- `/api/assign_ticket.php` - Assign ticket to user
- `/api/add_comment.php` - Add comment to ticket
- `/api/get_template.php` - Fetch ticket template
- `/api/get_users.php` - Get user list for assignments
- `/api/bulk_operation.php` - Perform bulk operations (admin only)
## 🚀 Setup & Configuration | Endpoint | Method | Description |
|----------|--------|-------------|
| `/create_ticket_api.php` | POST | Create ticket via API key (hwmonDaemon, external tools) |
| `/api/update_ticket.php` | POST | Update ticket with workflow validation |
| `/api/assign_ticket.php` | POST | Assign ticket to user |
| `/api/add_comment.php` | POST | Add comment to ticket |
| `/api/clone_ticket.php` | POST | Clone an existing ticket |
| `/api/get_template.php` | GET | Fetch ticket template |
| `/api/get_users.php` | GET | Get user list for assignments |
| `/api/bulk_operation.php` | POST | Perform bulk operations |
| `/api/ticket_dependencies.php` | GET/POST/DELETE | Manage dependencies |
| `/api/upload_attachment.php` | GET/POST | List or upload attachments |
| `/api/export_tickets.php` | GET | Export tickets to CSV/JSON |
| `/api/generate_api_key.php` | POST | Generate API key (admin) |
| `/api/revoke_api_key.php` | POST | Revoke API key (admin) |
| `/api/delete_comment.php` | POST | Delete comment (owner/admin) |
| `/api/update_comment.php` | POST | Update comment (owner/admin) |
| `/api/delete_attachment.php` | POST/DELETE | Delete attachment |
| `/api/download_attachment.php` | GET | Download attachment (visibility checked) |
| `/api/check_duplicates.php` | GET | Check for duplicate tickets |
| `/api/manage_recurring.php` | CRUD | Recurring tickets (admin) |
| `/api/manage_templates.php` | CRUD | Templates (admin) |
| `/api/manage_workflows.php` | CRUD | Workflow rules (admin) |
| `/api/custom_fields.php` | CRUD | Custom field definitions/values (admin) |
| `/api/saved_filters.php` | CRUD | Saved filter combinations |
| `/api/user_preferences.php` | GET/POST | User preferences |
| `/api/notifications.php` | GET/POST | In-app notifications (bell) |
| `/api/user_avatar.php` | GET | User avatar from lldap (cached JPEG) |
| `/api/audit_log.php` | GET | Audit log entries (admin) |
| `/api/watch_ticket.php` | POST | Watch/unwatch a ticket |
| `/api/health.php` | GET | Health check |
## Project Structure
```
tinker_tickets/
├── api/
│ ├── add_comment.php # POST: Add comment
│ ├── assign_ticket.php # POST: Assign ticket to user
│ ├── audit_log.php # GET: Audit log entries (admin)
│ ├── bootstrap.php # Shared auth/setup include (not a public endpoint)
│ ├── bulk_operation.php # POST: Bulk operations (admin only)
│ ├── check_duplicates.php # GET: Check for duplicate tickets
│ ├── clone_ticket.php # POST: Clone an existing ticket
│ ├── custom_fields.php # CRUD: Custom field definitions/values (admin)
│ ├── delete_attachment.php # POST/DELETE: Delete attachment
│ ├── delete_comment.php # POST: Delete comment (owner/admin)
│ ├── download_attachment.php # GET: Download with visibility check
│ ├── export_tickets.php # GET: Export tickets to CSV/JSON
│ ├── generate_api_key.php # POST: Generate API key (admin)
│ ├── get_template.php # GET: Fetch ticket template
│ ├── get_users.php # GET: Get user list
│ ├── health.php # GET: Health check endpoint
│ ├── manage_recurring.php # CRUD: Recurring tickets (admin)
│ ├── manage_templates.php # CRUD: Templates (admin)
│ ├── manage_workflows.php # CRUD: Workflow rules (admin)
│ ├── notifications.php # GET/POST: In-app notification bell
│ ├── revoke_api_key.php # POST: Revoke API key (admin)
│ ├── saved_filters.php # CRUD: Saved filter combinations
│ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies
│ ├── update_comment.php # POST: Update comment (owner/admin)
│ ├── update_ticket.php # POST: Update ticket (workflow validation)
│ ├── upload_attachment.php # GET/POST: List or upload attachments
│ ├── user_avatar.php # GET: LDAP avatar proxy with disk cache
│ ├── user_preferences.php # GET/POST: User preferences
│ └── watch_ticket.php # POST: Watch/unwatch a ticket
├── assets/
│ ├── css/
│ │ ├── base.css # LotusGuild Terminal Design System (copied from web_template)
│ │ ├── dashboard.css # Dashboard + terminal styling
│ │ └── ticket.css # Ticket view styling
│ ├── js/
│ │ ├── advanced-search.js # Advanced search modal
│ │ ├── ascii-banner.js # ASCII art banner (rendered in boot overlay on first visit)
│ │ ├── base.js # LotusGuild JS utilities — window.lt (copied from web_template)
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar + charts
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts (uses lt.keys)
│ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe)
│ │ ├── settings.js # User preferences
│ │ ├── ticket.js # Ticket + comments + visibility + @mention
│ │ ├── toast.js # Deprecated shim (no longer loaded — all callers use lt.toast directly)
│ │ └── utils.js # escapeHtml (→ lt.escHtml), getTicketIdFromUrl, showConfirmModal
│ └── images/
│ └── favicon.png
├── config/
│ └── config.php # Config + .env loading
├── controllers/
│ ├── DashboardController.php # Dashboard with stats + filters
│ └── TicketController.php # Ticket CRUD + timeline + visibility
├── cron/
│ └── create_recurring_tickets.php # Process recurring ticket schedules
├── helpers/
│ ├── CacheHelper.php # File-based cache (stats, avatars)
│ ├── Database.php # Centralized mysqli connection
│ ├── NotificationHelper.php # Matrix hookshot webhook events
│ ├── SynapseHelper.php # Resolves usernames → Matrix IDs via Synapse admin API
│ └── UrlHelper.php # Canonical ticket URLs using APP_DOMAIN
├── middleware/
│ ├── ApiKeyAuth.php # Bearer token auth for external API (hwmonDaemon)
│ ├── AuthMiddleware.php # Authelia SSO integration
│ ├── CsrfMiddleware.php # CSRF protection
│ ├── RateLimitMiddleware.php # Session + IP-based rate limiting
│ └── SecurityHeadersMiddleware.php # CSP headers with per-request nonce generation
├── models/
│ ├── ApiKeyModel.php # API key generation/validation
│ ├── AuditLogModel.php # Audit logging + timeline
│ ├── BulkOperationsModel.php # Bulk operations tracking
│ ├── CommentModel.php # Comment data access
│ ├── CustomFieldModel.php # Custom field definitions/values
│ ├── DependencyModel.php # Ticket dependencies
│ ├── RecurringTicketModel.php # Recurring ticket schedules
│ ├── SavedFiltersModel.php # Saved filter combinations
│ ├── StatsModel.php # Dashboard statistics (cached)
│ ├── TemplateModel.php # Ticket templates
│ ├── TicketModel.php # Ticket CRUD + visibility + collision-safe IDs
│ ├── UserModel.php # User management + groups
│ ├── UserPreferencesModel.php # User preferences
│ └── WorkflowModel.php # Status transition workflows
├── scripts/
│ ├── add_closed_at_column.php # Migration: add closed_at column to tickets
│ ├── add_comment_updated_at.php # Migration: add updated_at column to ticket_comments
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads (run manually or via cron)
│ └── create_dependencies_table.php # Create ticket_dependencies table
├── uploads/ # File attachment storage
│ └── avatars/ # lldap avatar disk cache
├── views/
│ ├── admin/
│ │ ├── ApiKeysView.php # API key management
│ │ ├── AuditLogView.php # Audit log browser
│ │ ├── CustomFieldsView.php # Custom field management
│ │ ├── RecurringTicketsView.php # Recurring ticket management
│ │ ├── TemplatesView.php # Template management
│ │ ├── UserActivityView.php # User activity report
│ │ └── WorkflowDesignerView.php # Workflow transition designer
│ ├── CreateTicketView.php # Ticket creation with visibility
│ ├── DashboardView.php # Dashboard with kanban + sidebar + charts
│ ├── layout_footer.php # Shared footer (notification polling, boot sequence)
│ ├── layout_header.php # Shared header (nav, command palette, theme toggle)
│ └── TicketView.php # Ticket view with timeline, SLA, watcher avatars
├── .env # Environment variables (GITIGNORED)
├── create_ticket_api.php # External API endpoint (hwmonDaemon, API-key auth)
├── README.md # This file
└── index.php # Main router
```
## Workflow States
### Default Workflow
```
Open → Pending → In Progress → Closed
↑ ↑
└───────────┘
```
All states can transition to Closed (with comment).
Closed tickets can be reopened to Open or In Progress.
## Setup & Configuration
### 1. Environment Configuration ### 1. Environment Configuration
Create `.env` file in project root: Copy the example file and edit with your values:
```bash
cp .env.example .env
nano .env
```
Required environment variables:
```env ```env
DB_HOST=10.10.10.50 DB_HOST=your_db_host
DB_USER=tinkertickets DB_USER=your_db_user
DB_PASS=your_password DB_PASS=your_password
DB_NAME=ticketing_system DB_NAME=ticketing_system
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... APP_DOMAIN=your.domain.example
TIMEZONE=America/New_York
``` ```
### 2. Database Setup Matrix notification variables (all optional):
```env
# hookshot generic webhook URL — send events to Matrix room
MATRIX_WEBHOOK_URL=https://matrix.lotusguild.org/_hookshot/webhook/...
Run migrations in order: # Comma-separated Matrix user IDs to @mention on new tickets / status changes
MATRIX_NOTIFY_USERS=@jared:matrix.lotusguild.org,@ops:matrix.lotusguild.org
# Matrix homeserver domain (used to build Matrix user IDs from LLDAP usernames)
MATRIX_DOMAIN=matrix.lotusguild.org
# Synapse internal URL and admin token (used to resolve usernames → Matrix IDs for watcher DMs)
SYNAPSE_ADMIN_URL=http://10.10.10.29:8008
SYNAPSE_ADMIN_TOKEN=your_synapse_admin_token
# Optional: send Matrix notification on comments and/or assignments
MATRIX_NOTIFY_COMMENTS=0
MATRIX_NOTIFY_ASSIGNMENTS=1
```
LDAP/avatar variables (optional):
```env
LDAP_ENABLED=true
LDAP_HOST=10.10.10.39
LDAP_PORT=3890
LDAP_BIND_DN=uid=tinker-tickets,ou=people,dc=example,dc=com
LDAP_BIND_PW=your_bind_password
LDAP_BASE_DN=dc=example,dc=com
LDAP_USER_BASE=ou=people,dc=example,dc=com
AVATAR_CACHE_TTL=3600
```
**Note**: `APP_DOMAIN` is required for Matrix webhook ticket links to work correctly. Without it, links will default to localhost.
### 2. Cron Jobs
Add to crontab for recurring tickets and optional cleanup:
```bash ```bash
# Navigate to project directory # Run every hour to create scheduled recurring tickets
cd /root/code/tinker_tickets 0 * * * * php /path/to/tinkertickets/cron/create_recurring_tickets.php
# Run each migration # Optional: clean up orphaned uploads weekly
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/001_initial_schema.sql 0 3 * * 0 php /path/to/tinkertickets/scripts/cleanup_orphan_uploads.php
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/007_add_ticket_assignment.sql
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/008_add_status_workflows.sql
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/009_add_ticket_templates.sql
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/010_add_bulk_operations.sql
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/011_remove_view_tracking.sql
``` ```
### 3. Web Server Configuration ### 3. File Uploads
**Apache Configuration** (recommended): Ensure the `uploads/` directory exists and is writable:
```apache ```bash
<VirtualHost *:80> mkdir -p /path/to/tinkertickets/uploads/avatars
ServerName t.lotusguild.org chown www-data:www-data /path/to/tinkertickets/uploads
DocumentRoot /root/code/tinker_tickets chmod 755 /path/to/tinkertickets/uploads
<Directory /root/code/tinker_tickets>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
# Enable mod_rewrite for clean URLs
RewriteEngine On
RewriteBase /
# Route ticket URLs
RewriteRule ^ticket/([0-9]+)$ ticket.php?id=$1 [L,QSA]
# Route ticket create
RewriteRule ^ticket/create$ ticket.php?action=create [L,QSA]
</Directory>
</VirtualHost>
``` ```
### 4. Authelia Integration ### 4. Authelia Integration
@@ -147,128 +481,103 @@ Tinker Tickets uses Authelia for SSO. User information is passed via headers:
- `Remote-Email`: Email address - `Remote-Email`: Email address
- `Remote-Groups`: User groups (comma-separated) - `Remote-Groups`: User groups (comma-separated)
Admin users must be in the `admins` group in LLDAP. Admin users must be in the `admin` group in LLDAP.
## 📁 Project Structure ### 5. hwmonDaemon API Key
``` 1. Go to `/admin/api-keys` and generate a new key named e.g. "hwmonDaemon"
tinker_tickets/ 2. Copy the displayed key (shown only once)
├── api/ # API endpoints 3. On each monitored server, create `/etc/hwmonDaemon/.env`:
│ ├── add_comment.php ```env
│ ├── assign_ticket.php TICKET_API_KEY=your_generated_key
├── bulk_operation.php TICKET_API_URL=http://10.10.10.45/create_ticket_api.php
│ ├── get_template.php ```
│ ├── get_users.php
│ └── update_ticket.php
├── assets/ # Static assets
│ ├── css/
│ │ ├── dashboard.css
│ │ └── ticket.css
│ └── js/
│ ├── dashboard.js
│ └── ticket.js
├── config/ # Configuration
│ └── config.php
├── controllers/ # MVC Controllers
│ ├── DashboardController.php
│ └── TicketController.php
├── models/ # Data models
│ ├── AuditLogModel.php
│ ├── BulkOperationsModel.php
│ ├── CommentModel.php
│ ├── TemplateModel.php
│ ├── TicketModel.php
│ ├── UserModel.php
│ └── WorkflowModel.php
├── views/ # View templates
│ ├── CreateTicketView.php
│ ├── DashboardView.php
│ └── TicketView.php
├── migrations/ # Database migrations
│ ├── 001_initial_schema.sql
│ ├── 007_add_ticket_assignment.sql
│ ├── 008_add_status_workflows.sql
│ ├── 009_add_ticket_templates.sql
│ ├── 010_add_bulk_operations.sql
│ └── 011_remove_view_tracking.sql
├── index.php # Dashboard entry point
├── ticket.php # Ticket view/create entry point
└── .env # Environment configuration
```
## 🔐 Security Features ## Developer Notes
- **SQL Injection Prevention**: All queries use prepared statements Key conventions and gotchas for working with this codebase:
- **XSS Protection**: All output is properly escaped with `htmlspecialchars()`
- **Session Security**: Secure PHP session handling
- **Admin Validation**: Server-side admin checks for privileged operations
- **Workflow Enforcement**: Status transitions validated server-side
- **Audit Logging**: Complete audit trail of all actions
## 🎯 Workflow States 1. **Session format**: `$_SESSION['user']['user_id']` (not `$_SESSION['user_id']`)
2. **API auth check**: Verify `$_SESSION['user']['user_id']` exists
3. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
4. **Config path**: `config/config.php` (not `config/db.php`)
5. **Comments table**: `ticket_comments` (not `comments`)
6. **CSRF**: Required for all POST/DELETE requests via `X-CSRF-Token` header; bootstrap.php rotates token and returns it in `csrf_token` field of all `apiRespond()` responses
7. **Cache busting**: `ASSET_VERSION` is auto-computed from asset file mtimes; override with `ASSET_VERSION=` in `.env`
8. **Ticket linking**: Use `#123456789` in markdown-enabled comments
9. **User groups**: Stored in `users.groups` as comma-separated values
10. **API routing**: All API endpoints must be registered in `index.php` router
11. **Session in APIs**: `RateLimitMiddleware` starts the session — guard subsequent `session_start()` calls with `if (session_status() === PHP_SESSION_NONE)`
12. **Database collation**: Use `utf8mb4_general_ci` (not `unicode_ci`) for new tables
13. **Matrix URLs**: Set `APP_DOMAIN` in `.env` for correct ticket URLs in Matrix notifications
14. **Ticket ID extraction**: Use `getTicketIdFromUrl()` helper in JS files
15. **CSP nonces**: All inline `<script>` tags require `nonce="<?php echo $nonce; ?>"`
16. **Visibility validation**: Internal visibility requires at least one group — validated server-side
17. **Rate limiting**: Both session-based AND IP-based limits are enforced
18. **Relative timestamps**: Dashboard dates use `lt.time.ago()` and refresh every 60 s; full date is always in the `title` attribute for hover
19. **Boot sequence**: Shows ASCII banner then boot messages on first page visit per session (`sessionStorage.getItem('booted')`); removed on subsequent loads
20. **No raw `fetch()`**: All AJAX calls use `lt.api.get/post/put/delete()` — never use raw `fetch()`. The wrapper auto-adds `Content-Type: application/json` and `X-CSRF-Token`, auto-parses JSON, and throws on non-2xx.
21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`.
22. **`utils.js` on all pages**: `utils.js` is loaded by all views (including admin). It provides `escapeHtml()`, `getTicketIdFromUrl()`, and `showConfirmModal()`.
23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`.
24. **Stats cache**: `StatsModel` caches stats for 60 s. Any API that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after changes (bulk_operation, assign_ticket, update_ticket, clone_ticket all do this).
25. **External API (`create_ticket_api.php`)**: Uses `ApiKeyAuth` (Bearer token), not session auth. Served directly by the web server from the document root — not through the index.php router. Includes deduplication logic to prevent duplicate hw-alert tickets within 24 h.
### Default Workflow ## File Reference
```
Open → In Progress → Resolved → Closed
↓ ↓ ↓
└─────────┴──────────┘
(can reopen)
```
### Workflow Configuration | File | Purpose |
Status transitions are defined in the `status_transitions` table: |------|---------|
- `from_status`: Current status | `index.php` | Main router for all routes |
- `to_status`: Target status | `create_ticket_api.php` | External API (hwmonDaemon) — Bearer token auth, deduplication |
- `requires_comment`: Whether transition requires a comment | `config/config.php` | Config loader + .env parsing |
- `requires_admin`: Whether transition requires admin privileges | `api/update_ticket.php` | Ticket updates with workflow + visibility validation |
- `is_active`: Whether transition is enabled | `api/notifications.php` | In-app notification bell — reads from audit_log |
| `api/user_avatar.php` | LDAP avatar proxy with disk cache |
| `api/download_attachment.php` | File downloads with visibility check |
| `api/bulk_operation.php` | Bulk operations with visibility filtering |
| `models/TicketModel.php` | Ticket CRUD, visibility filtering, collision-safe ID generation |
| `models/ApiKeyModel.php` | API key generation and validation |
| `models/StatsModel.php` | Dashboard statistics (60 s cache; invalidated on ticket changes) |
| `middleware/ApiKeyAuth.php` | Bearer token authentication for external API |
| `middleware/AuthMiddleware.php` | Authelia header parsing + session setup |
| `middleware/CsrfMiddleware.php` | CSRF token generation and validation |
| `middleware/SecurityHeadersMiddleware.php` | CSP headers with per-request nonce generation |
| `middleware/RateLimitMiddleware.php` | Session + IP-based rate limiting |
| `helpers/NotificationHelper.php` | Matrix hookshot webhook events |
| `helpers/SynapseHelper.php` | Username → Matrix ID resolution via Synapse admin API |
| `assets/js/dashboard.js` | Dashboard UI, kanban, sidebar, bulk actions, charts, command palette |
| `assets/js/ticket.js` | Ticket UI, @mention autocomplete, lightbox, visibility editing |
| `assets/js/markdown.js` | Markdown parsing + ticket linking (XSS-safe) |
| `assets/css/dashboard.css` | Terminal styling, kanban, sidebar, charts, workload panel |
| `assets/css/ticket.css` | Ticket view: SLA progress, attachment thumbnails, timeline |
## 📝 Usage Examples ## Security Implementations
### Creating a Ticket | Feature | Implementation |
1. Click "New Ticket" button |---------|---------------|
2. Select template (optional) - auto-fills common fields | SQL Injection | All queries use prepared statements with parameter binding |
3. Fill in title, description, category, type, priority | XSS Prevention | HTML escaped in markdown parser; CSP with per-request nonces |
4. Click "Create Ticket" | CSRF Protection | Token-based with constant-time comparison (`hash_equals`); rotated on each write |
| Session Security | Fixation prevention, secure cookies, session timeout |
| Rate Limiting | Session-based + IP-based (file storage) |
| File Security | Path traversal prevention, MIME type validation, uploads `.htaccess` blocks execution |
| Visibility | Enforced on ticket views, downloads, and bulk operations |
| API Key Auth | SHA-256 hashed keys stored in DB; Bearer token auth for external API |
### Updating Ticket Status ## CI / CD
1. Open ticket
2. Click status dropdown (next to priority badge)
3. Select allowed status (workflow-validated)
4. Confirm if comment is required
### Assigning Tickets | Workflow | Purpose | Triggers |
1. Open ticket or use dashboard bulk actions |---|---|---|
2. Select user from "Assigned to" dropdown | `lint.yml` (php-lint) | phpcs PSR-12 standard | Every push and PR |
3. Changes are auto-saved | `lint.yml` (js-lint) | ESLint on `assets/js/` | Every push and PR |
| `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 |
### Bulk Operations (Admin Only) Branch protection is enabled on `main` — both lint jobs must pass before any PR can merge.
1. Check multiple tickets on dashboard
2. Select bulk action (Close, Assign, Change Priority)
3. Complete operation
4. All actions are logged in audit trail
## 🔮 Roadmap Lint config: `.phpcs.xml` (PSR-12 with project-specific tweaks), `.eslintrc.json` (root, browser env).
- ✅ Activity Timeline ## License
- ✅ Ticket Assignment
- ✅ Status Transitions with Workflows
- ✅ Ticket Templates
- ✅ Bulk Actions (Admin Only)
- 🎨 **ANSI Art Redesign** (Next Priority)
- 🔗 Ticket Dependencies (blocks/blocked by)
- 📊 Custom Dashboard Widgets
- 🔧 Custom Fields per Category
## 🤝 Contributing
This is an internal tool for LotusGuild infrastructure management. For feature requests or bug reports, contact the infrastructure team.
## 📄 License
Internal use only - LotusGuild Infrastructure Internal use only - LotusGuild Infrastructure
## 🙏 Credits
Built with ❤️ for the LotusGuild community
Powered by PHP, MariaDB, and lots of coffee ☕
+108 -18
View File
@@ -1,8 +1,13 @@
<?php <?php
// Disable error display in the output // Disable error display in the output
ini_set('display_errors', 0); ini_set('display_errors', 0);
error_reporting(E_ALL); error_reporting(E_ALL);
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
// Start output buffering to capture any errors // Start output buffering to capture any errors
ob_start(); ob_start();
@@ -23,9 +28,15 @@ try {
require_once $configPath; require_once $configPath;
require_once $commentModelPath; require_once $commentModelPath;
require_once $auditLogModelPath; require_once $auditLogModelPath;
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
// Check authentication via session // Check authentication via session
session_start(); if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required"); throw new Exception("Authentication required");
} }
@@ -45,17 +56,8 @@ try {
$currentUser = $_SESSION['user']; $currentUser = $_SESSION['user'];
$userId = $currentUser['user_id']; $userId = $currentUser['user_id'];
// Create database connection // Use centralized database connection
$conn = new mysqli( $conn = Database::getConnection();
$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);
}
// Get POST data // Get POST data
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
@@ -64,40 +66,128 @@ try {
throw new Exception("Invalid JSON data received"); throw new Exception("Invalid JSON data received");
} }
$ticketId = $data['ticket_id']; $ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
if (!ctype_digit($ticketId) || (int)$ticketId <= 0) {
http_response_code(400);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
exit;
}
// Verify user can access the ticket before allowing a comment
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
http_response_code(404);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(403);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Access denied']);
exit;
}
// Initialize models // Initialize models
$commentModel = new CommentModel($conn); $commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn); $auditLog = new AuditLogModel($conn);
// Extract @mentions from comment text
$mentions = $commentModel->extractMentions($data['comment_text'] ?? '');
$mentionedUsers = [];
if (!empty($mentions)) {
$mentionedUsers = $commentModel->getMentionedUsers($mentions);
}
// Add comment with user tracking // Add comment with user tracking
$result = $commentModel->addComment($ticketId, $data, $userId); $result = $commentModel->addComment($ticketId, $data, $userId);
// Log comment creation to audit log // Log comment creation to audit log
if ($result['success'] && isset($result['comment_id'])) { if ($result['success'] && isset($result['comment_id'])) {
$auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId); $auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
// Log mentions to audit log
foreach ($mentionedUsers as $mentionedUser) {
$auditLog->log(
$userId,
'mention',
'user',
(string)$mentionedUser['user_id'],
[
'ticket_id' => $ticketId,
'comment_id' => $result['comment_id'],
'mentioned_username' => $mentionedUser['username']
]
);
}
// Matrix notifications
$authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$commentText = $data['comment_text'] ?? '';
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API
if (!empty($mentionedUsers)) {
$mentionedUsernames = array_column($mentionedUsers, 'username');
$mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
if (!empty($mentionedMatrixIds)) {
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
}
}
// General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
NotificationHelper::sendCommentNotification($ticketId, $ticketTitle, $commentText, $authorDisplay);
}
// Notify watchers of the new comment
NotificationHelper::notifyWatchers(
$conn,
$ticketId,
$ticketTitle,
'comment_added',
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
(int)$userId
);
// Add mentioned users to result for frontend
$result['mentions'] = array_map(function ($u) {
return $u['username'];
}, $mentionedUsers);
} }
// Add user display name to result for frontend // Add user info to result for frontend avatar rendering
if ($result['success']) { if ($result['success']) {
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username']; $result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
$result['user_id'] = $userId;
} }
// Discard any unexpected output // Discard any unexpected output
ob_end_clean(); ob_end_clean();
// Return JSON response // Return JSON response
if ($result['success']) {
http_response_code(201);
}
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode($result); echo json_encode($result);
} catch (Exception $e) { } catch (Exception $e) {
// Discard any unexpected output // Discard any unexpected output
ob_end_clean(); ob_end_clean();
// Log error details but don't expose to client
error_log("Add comment API error: " . $e->getMessage());
// Return error response // Return error response
http_response_code(500);
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode([ echo json_encode([
'success' => false, 'success' => false,
'error' => $e->getMessage() 'error' => 'An internal error occurred'
]); ]);
} }
+63 -41
View File
@@ -1,55 +1,48 @@
<?php <?php
session_start();
require_once dirname(__DIR__) . '/config/config.php'; require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
header('Content-Type: application/json'); require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit;
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
$userId = $_SESSION['user']['user_id'];
// Get request data // Get request data
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
$ticketId = $data['ticket_id'] ?? null; if (!is_array($data)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
exit;
}
$ticketIdRaw = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
$assignedTo = $data['assigned_to'] ?? null; $assignedTo = $data['assigned_to'] ?? null;
if (!$ticketId) { if (!ctype_digit($ticketIdRaw) || (int)$ticketIdRaw <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Ticket ID required']); echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
exit; exit;
} }
$ticketId = $ticketIdRaw;
// 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) {
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
exit;
}
$ticketModel = new TicketModel($conn); $ticketModel = new TicketModel($conn);
$auditLogModel = new AuditLogModel($conn); $auditLogModel = new AuditLogModel($conn);
$userModel = new UserModel($conn);
// Verify ticket exists and user can access it
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
// Authorization: only admins or the ticket creator/assignee can reassign
if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Permission denied']);
exit;
}
if ($assignedTo === null || $assignedTo === '') { if ($assignedTo === null || $assignedTo === '') {
// Unassign ticket // Unassign ticket
@@ -58,13 +51,42 @@ if ($assignedTo === null || $assignedTo === '') {
$auditLogModel->log($userId, 'unassign', 'ticket', $ticketId); $auditLogModel->log($userId, 'unassign', 'ticket', $ticketId);
} }
} else { } else {
// Validate assigned_to is a valid user ID
$assignedTo = (int)$assignedTo;
$targetUser = $userModel->getUserById($assignedTo);
if (!$targetUser) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid user ID']);
exit;
}
// Assign ticket // Assign ticket
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId); $success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
if ($success) { if ($success) {
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]); $auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) {
$changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null;
$assigneeMatrix = isset($targetUser['username'])
? SynapseHelper::resolveUsername($targetUser['username'])
: null;
NotificationHelper::sendAssignmentNotification(
$ticketId,
$ticket['title'] ?? "Ticket #{$ticketId}",
$assigneeName,
$assigneeMatrix,
$changedByDisplay
);
}
} }
} }
$conn->close(); if (!$success) {
http_response_code(500);
echo json_encode(['success' => $success]); apiRespond(['success' => false, 'error' => 'Failed to update ticket assignment']);
} else {
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($conn))->invalidateCache();
apiRespond(['success' => true]);
}
+46 -46
View File
@@ -1,45 +1,21 @@
<?php <?php
/** /**
* Audit Log API Endpoint * Audit Log API Endpoint
* Handles fetching filtered audit logs and CSV export * Handles fetching filtered audit logs and CSV export
* Admin-only access * Admin-only access
*/ */
require_once dirname(__DIR__) . '/config/config.php'; require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php';
session_start();
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit;
}
// Check admin status - audit log viewing is admin-only // Check admin status - audit log viewing is admin-only
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
if (!$isAdmin) { if (!$isAdmin) {
http_response_code(403); http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Admin access required']); echo json_encode(['success' => false, 'error' => 'Admin access required']);
exit; exit;
} }
// 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) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
exit;
}
$auditLogModel = new AuditLogModel($conn); $auditLogModel = new AuditLogModel($conn);
// GET - Fetch filtered audit logs or export to CSV // GET - Fetch filtered audit logs or export to CSV
@@ -48,13 +24,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
if (isset($_GET['export']) && $_GET['export'] === 'csv') { if (isset($_GET['export']) && $_GET['export'] === 'csv') {
// Build filters // Build filters
$filters = []; $filters = [];
if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type']; if (isset($_GET['action_type'])) {
if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type']; $filters['action_type'] = $_GET['action_type'];
if (isset($_GET['user_id'])) $filters['user_id'] = $_GET['user_id']; }
if (isset($_GET['entity_id'])) $filters['entity_id'] = $_GET['entity_id']; if (isset($_GET['entity_type'])) {
if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from']; $filters['entity_type'] = $_GET['entity_type'];
if (isset($_GET['date_to'])) $filters['date_to'] = $_GET['date_to']; }
if (isset($_GET['ip_address'])) $filters['ip_address'] = $_GET['ip_address']; if (isset($_GET['user_id'])) {
$filters['user_id'] = $_GET['user_id'];
}
if (isset($_GET['entity_id'])) {
$filters['entity_id'] = $_GET['entity_id'];
}
if (isset($_GET['date_from'])) {
$filters['date_from'] = $_GET['date_from'];
}
if (isset($_GET['date_to'])) {
$filters['date_to'] = $_GET['date_to'];
}
if (isset($_GET['ip_address'])) {
$filters['ip_address'] = $_GET['ip_address'];
}
// Get all matching logs (no limit for CSV export) // Get all matching logs (no limit for CSV export)
$result = $auditLogModel->getFilteredLogs($filters, 10000, 0); $result = $auditLogModel->getFilteredLogs($filters, 10000, 0);
@@ -90,26 +80,39 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
} }
fclose($output); fclose($output);
$conn->close();
exit; exit;
} }
// Normal JSON response for filtered logs // Normal JSON response for filtered logs
try { try {
// Get pagination parameters // Get pagination parameters
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1; $page = max(1, (int)($_GET['page'] ?? 1));
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 50; $limit = min(500, max(1, (int)($_GET['limit'] ?? 50)));
$offset = ($page - 1) * $limit; $offset = ($page - 1) * $limit;
// Build filters // Build filters
$filters = []; $filters = [];
if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type']; if (isset($_GET['action_type'])) {
if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type']; $filters['action_type'] = $_GET['action_type'];
if (isset($_GET['user_id'])) $filters['user_id'] = $_GET['user_id']; }
if (isset($_GET['entity_id'])) $filters['entity_id'] = $_GET['entity_id']; if (isset($_GET['entity_type'])) {
if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from']; $filters['entity_type'] = $_GET['entity_type'];
if (isset($_GET['date_to'])) $filters['date_to'] = $_GET['date_to']; }
if (isset($_GET['ip_address'])) $filters['ip_address'] = $_GET['ip_address']; if (isset($_GET['user_id'])) {
$filters['user_id'] = $_GET['user_id'];
}
if (isset($_GET['entity_id'])) {
$filters['entity_id'] = $_GET['entity_id'];
}
if (isset($_GET['date_from'])) {
$filters['date_from'] = $_GET['date_from'];
}
if (isset($_GET['date_to'])) {
$filters['date_to'] = $_GET['date_to'];
}
if (isset($_GET['ip_address'])) {
$filters['ip_address'] = $_GET['ip_address'];
}
// Get filtered logs // Get filtered logs
$result = $auditLogModel->getFilteredLogs($filters, $limit, $offset); $result = $auditLogModel->getFilteredLogs($filters, $limit, $offset);
@@ -125,12 +128,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to fetch audit logs']); echo json_encode(['success' => false, 'error' => 'Failed to fetch audit logs']);
} }
$conn->close();
exit; exit;
} }
// Method not allowed // Method not allowed
http_response_code(405); http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']); echo json_encode(['success' => false, 'error' => 'Method not allowed']);
$conn->close();
?>
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* API Bootstrap - Common setup for API endpoints
*
* Provides: $conn, $currentUser, $userId, $isAdmin
*
* Usage:
* require_once __DIR__ . '/bootstrap.php';
* // $conn, $currentUser, $userId, $isAdmin are now available
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
// Rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
// Config and database
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
// Authentication check
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// CSRF protection for write requests
if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'DELETE'])) {
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
// Rotate token after successful validation; endpoints include it in their JSON response
$GLOBALS['_new_csrf_token'] = CsrfMiddleware::rotateToken();
}
header('Content-Type: application/json');
// Common variables
$currentUser = $_SESSION['user'];
$userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false;
$conn = Database::getConnection();
/**
* Output a JSON response, appending the rotated CSRF token so the
* client-side lt.api interceptor can update window.CSRF_TOKEN.
*/
function apiRespond(array $data): void
{
if (!empty($GLOBALS['_new_csrf_token'])) {
$data['csrf_token'] = $GLOBALS['_new_csrf_token'];
}
echo json_encode($data);
exit;
}
+58 -24
View File
@@ -1,6 +1,11 @@
<?php <?php
session_start();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
require_once dirname(__DIR__) . '/config/config.php'; require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/BulkOperationsModel.php'; require_once dirname(__DIR__) . '/models/BulkOperationsModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php';
@@ -9,6 +14,7 @@ header('Content-Type: application/json');
// Check authentication // Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Not authenticated']); echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit; exit;
} }
@@ -27,6 +33,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Check admin status - bulk operations are admin-only // Check admin status - bulk operations are admin-only
$isAdmin = $_SESSION['user']['is_admin'] ?? false; $isAdmin = $_SESSION['user']['is_admin'] ?? false;
if (!$isAdmin) { if (!$isAdmin) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Admin access required']); echo json_encode(['success' => false, 'error' => 'Admin access required']);
exit; exit;
} }
@@ -38,39 +45,57 @@ $ticketIds = $data['ticket_ids'] ?? [];
$parameters = $data['parameters'] ?? null; $parameters = $data['parameters'] ?? null;
// Validate input // Validate input
if (!$operationType || empty($ticketIds)) { $validOperationTypes = ['bulk_close', 'bulk_assign', 'bulk_priority', 'bulk_status', 'bulk_delete'];
if (!$operationType || !in_array($operationType, $validOperationTypes, true) || empty($ticketIds)) {
echo json_encode(['success' => false, 'error' => 'Operation type and ticket IDs required']); echo json_encode(['success' => false, 'error' => 'Operation type and ticket IDs required']);
exit; exit;
} }
// Validate ticket IDs are integers // Validate ticket IDs: must be non-empty numeric strings (allows leading zeros)
foreach ($ticketIds as $ticketId) { $ticketIds = array_values(array_filter(array_map(function ($id) {
if (!is_numeric($ticketId)) { $s = trim((string)$id);
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID format']); return (ctype_digit($s) && (int)$s > 0) ? $s : null;
exit; }, $ticketIds)));
} if (empty($ticketIds)) {
} echo json_encode(['success' => false, 'error' => 'No valid ticket IDs provided']);
// 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) {
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
exit; exit;
} }
// Use centralized database connection
$conn = Database::getConnection();
$bulkOpsModel = new BulkOperationsModel($conn); $bulkOpsModel = new BulkOperationsModel($conn);
$ticketModel = new TicketModel($conn);
// Verify user can access all tickets in the bulk operation
// (Admins can access all, but this is defense-in-depth)
$accessibleTicketIds = [];
$inaccessibleCount = 0;
$tickets = $ticketModel->getTicketsByIds($ticketIds);
foreach ($ticketIds as $ticketId) {
$ticketId = trim($ticketId);
$ticket = $tickets[$ticketId] ?? null;
if ($ticket && $ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
$accessibleTicketIds[] = $ticketId;
} else {
$inaccessibleCount++;
}
}
if (empty($accessibleTicketIds)) {
echo json_encode(['success' => false, 'error' => 'No accessible tickets in selection']);
exit;
}
// Use only accessible ticket IDs
$ticketIds = $accessibleTicketIds;
// Create bulk operation record // Create bulk operation record
$operationId = $bulkOpsModel->createBulkOperation($operationType, $ticketIds, $_SESSION['user']['user_id'], $parameters); $operationId = $bulkOpsModel->createBulkOperation($operationType, $ticketIds, $_SESSION['user']['user_id'], $parameters);
if (!$operationId) { if (!$operationId) {
$conn->close();
echo json_encode(['success' => false, 'error' => 'Failed to create bulk operation']); echo json_encode(['success' => false, 'error' => 'Failed to create bulk operation']);
exit; exit;
} }
@@ -78,19 +103,28 @@ if (!$operationId) {
// Process the bulk operation // Process the bulk operation
$result = $bulkOpsModel->processBulkOperation($operationId); $result = $bulkOpsModel->processBulkOperation($operationId);
$conn->close();
if (isset($result['error'])) { if (isset($result['error'])) {
$conn->close();
echo json_encode([ echo json_encode([
'success' => false, 'success' => false,
'error' => $result['error'] 'error' => $result['error']
]); ]);
} else { } else {
// Invalidate stats cache so dashboard tiles reflect changes immediately
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($conn))->invalidateCache();
$conn->close();
$message = "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed";
if ($inaccessibleCount > 0) {
$message .= " ($inaccessibleCount skipped - no access)";
}
echo json_encode([ echo json_encode([
'success' => true, 'success' => true,
'operation_id' => $operationId, 'operation_id' => $operationId,
'processed' => $result['processed'], 'processed' => $result['processed'],
'failed' => $result['failed'], 'failed' => $result['failed'],
'message' => "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed" 'skipped' => $inaccessibleCount,
'message' => $message
]); ]);
} }
+100
View File
@@ -0,0 +1,100 @@
<?php
/**
* Check for duplicate tickets API
*
* Searches for tickets with similar titles using LIKE and SOUNDEX
*/
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
// Only accept GET requests
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
ResponseHelper::error('Method not allowed', 405);
}
// Get title parameter
$title = isset($_GET['title']) ? trim($_GET['title']) : '';
if (strlen($title) < 5) {
ResponseHelper::success(['duplicates' => []]);
}
// Search for similar titles
// Use both LIKE for substring matching and SOUNDEX for phonetic matching
$duplicates = [];
// Prepare search term for LIKE
$searchTerm = '%' . $title . '%';
// Get SOUNDEX of title
$soundexTitle = soundex($title);
// Build visibility filter so users only see titles they have access to
$ticketModel = new TicketModel($conn);
$visFilter = $ticketModel->getVisibilityFilter($currentUser);
// First, search for exact substring matches (case-insensitive)
$sql = "SELECT ticket_id, title, status, priority, created_at
FROM tickets
WHERE (
title LIKE ?
OR SOUNDEX(title) = ?
)
AND status != 'Closed'
AND ({$visFilter['sql']})
ORDER BY created_at DESC
LIMIT 10";
$types = "ss" . $visFilter['types'];
$params = array_merge([$searchTerm, $soundexTitle], $visFilter['params']);
$stmt = $conn->prepare($sql);
if (!empty($params)) {
$stmt->bind_param($types, ...$params);
}
$stmt->execute();
$result = $stmt->get_result();
while ($row = $result->fetch_assoc()) {
// Calculate similarity score
$similarity = 0;
// Check for exact substring match
if (stripos($row['title'], $title) !== false) {
$similarity = 90;
// Check SOUNDEX match
} elseif (soundex($row['title']) === $soundexTitle) {
$similarity = 70;
// Check word overlap
} else {
$titleWords = array_map('strtolower', preg_split('/\s+/', $title));
$rowWords = array_map('strtolower', preg_split('/\s+/', $row['title']));
$matchingWords = array_intersect($titleWords, $rowWords);
$similarity = (count($matchingWords) / max(count($titleWords), 1)) * 60;
}
if ($similarity >= 30) {
$duplicates[] = [
'ticket_id' => $row['ticket_id'],
'title' => $row['title'],
'status' => $row['status'],
'priority' => $row['priority'],
'created_at' => $row['created_at'],
'similarity' => round($similarity)
];
}
}
$stmt->close();
// Sort by similarity descending
usort($duplicates, function ($a, $b) {
return $b['similarity'] - $a['similarity'];
});
// Limit to top 5
$duplicates = array_slice($duplicates, 0, 5);
ResponseHelper::success(['duplicates' => $duplicates]);
+134
View File
@@ -0,0 +1,134 @@
<?php
/**
* Clone Ticket API
* Creates a copy of an existing ticket with the same properties
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
// Only accept POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
exit;
}
// Get request data
$input = file_get_contents('php://input');
$data = json_decode($input, true);
if (!$data || empty($data['ticket_id'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing ticket_id']);
exit;
}
$sourceTicketIdRaw = trim((string)$data['ticket_id']);
if (!ctype_digit($sourceTicketIdRaw) || (int)$sourceTicketIdRaw <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
exit;
}
$sourceTicketId = $sourceTicketIdRaw;
$userId = $_SESSION['user']['user_id'];
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
// Get database connection
$conn = Database::getConnection();
// Get the source ticket
$ticketModel = new TicketModel($conn);
$sourceTicket = $ticketModel->getTicketById($sourceTicketId);
if (!$sourceTicket) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Source ticket not found']);
exit;
}
// Verify the user can access this ticket using centralized visibility logic
if (!$ticketModel->canUserAccessTicket($sourceTicket, $_SESSION['user'])) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Source ticket not found']);
exit;
}
// Prepare cloned ticket data
$clonedTicketData = [
'title' => '[CLONE] ' . $sourceTicket['title'],
'description' => $sourceTicket['description'],
'priority' => $sourceTicket['priority'],
'category' => $sourceTicket['category'],
'type' => $sourceTicket['type'],
'visibility' => $sourceTicket['visibility'] ?? 'public',
'visibility_groups' => $sourceTicket['visibility_groups'] ?? null
];
// Create the cloned ticket
$result = $ticketModel->createTicket($clonedTicketData, $userId);
if ($result['success']) {
// Log the clone operation
$auditLog = new AuditLogModel($conn);
$auditLog->log($userId, 'create', 'ticket', $result['ticket_id'], [
'action' => 'clone',
'source_ticket_id' => $sourceTicket['ticket_id'],
'title' => $clonedTicketData['title']
]);
// Optionally create a "relates_to" dependency
require_once dirname(__DIR__) . '/models/DependencyModel.php';
$dependencyModel = new DependencyModel($conn);
$dependencyModel->addDependency($result['ticket_id'], $sourceTicket['ticket_id'], 'relates_to', $userId);
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($conn))->invalidateCache();
echo json_encode([
'success' => true,
'new_ticket_id' => $result['ticket_id'],
'message' => 'Ticket cloned successfully'
]);
} else {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => $result['error'] ?? 'Failed to create cloned ticket'
]);
}
} catch (Exception $e) {
error_log("Clone ticket API error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
}
+117
View File
@@ -0,0 +1,117 @@
<?php
/**
* Custom Fields Management API
* CRUD operations for custom field definitions
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
// Check authentication
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// Check admin privileges for write operations
if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !$_SESSION['user']['is_admin']) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
exit;
}
// CSRF Protection for write operations
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
// Use centralized database connection
$conn = Database::getConnection();
header('Content-Type: application/json');
$model = new CustomFieldModel($conn);
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
$category = isset($_GET['category']) ? $_GET['category'] : null;
switch ($method) {
case 'GET':
if ($id) {
$field = $model->getDefinition($id);
echo json_encode(['success' => (bool)$field, 'field' => $field]);
} else {
// Get all definitions, optionally filtered by category
$activeOnly = !isset($_GET['include_inactive']);
$fields = $model->getAllDefinitions($category, $activeOnly);
echo json_encode(['success' => true, 'fields' => $fields]);
}
break;
case 'POST':
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON']);
exit;
}
$result = $model->createDefinition($data);
echo json_encode($result);
break;
case 'PUT':
if (!$id) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ID required']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON']);
exit;
}
$result = $model->updateDefinition($id, $data);
echo json_encode($result);
break;
case 'DELETE':
if (!$id) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ID required']);
exit;
}
$result = $model->deleteDefinition($id);
echo json_encode($result);
break;
default:
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
}
} catch (Exception $e) {
error_log("Custom fields API error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
}
+120
View File
@@ -0,0 +1,120 @@
<?php
/**
* Delete Attachment API
*
* Handles deletion of ticket attachments
*/
// Capture errors for debugging
ini_set('display_errors', 0);
error_reporting(E_ALL);
// Apply rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
// Ensure session is started
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
ResponseHelper::unauthorized();
}
// Only accept DELETE or POST requests
if (!in_array($_SERVER['REQUEST_METHOD'], ['DELETE', 'POST'])) {
ResponseHelper::error('Method not allowed', 405);
}
// Get request body
$input = json_decode(file_get_contents('php://input'), true);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = array_merge($_POST, $input ?? []);
}
// Verify CSRF token
$csrfToken = $input['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ResponseHelper::forbidden('Invalid CSRF token');
}
// Get attachment ID
$attachmentId = isset($input['attachment_id']) ? (int)$input['attachment_id'] : 0;
if ($attachmentId <= 0 || (string)$attachmentId !== (string)($input['attachment_id'] ?? '')) {
ResponseHelper::error('Valid attachment ID is required');
}
try {
$attachmentModel = new AttachmentModel(Database::getConnection());
// Get attachment details
$attachment = $attachmentModel->getAttachment($attachmentId);
if (!$attachment) {
ResponseHelper::notFound('Attachment not found');
}
// Verify user can access the parent ticket
$ticketModel = new TicketModel(Database::getConnection());
$ticket = $ticketModel->getTicketById($attachment['ticket_id']);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
ResponseHelper::notFound('Attachment not found');
}
// Check permission (must be uploader or admin)
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
if (!$attachmentModel->canUserDelete($attachmentId, $_SESSION['user']['user_id'], $isAdmin)) {
ResponseHelper::forbidden('You do not have permission to delete this attachment');
}
// Delete the file — use realpath() to prevent path traversal
$uploadDir = realpath($GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads');
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
$realPath = realpath($filePath);
if ($realPath !== false) {
// Ensure the resolved path is still inside the upload directory
if (strncmp($realPath, $uploadDir . DIRECTORY_SEPARATOR, strlen($uploadDir) + 1) !== 0) {
ResponseHelper::forbidden('Access denied');
}
if (!unlink($realPath)) {
ResponseHelper::serverError('Failed to delete file');
}
}
// Delete from database
if (!$attachmentModel->deleteAttachment($attachmentId)) {
ResponseHelper::serverError('Failed to delete attachment record');
}
// Log the deletion
$conn = Database::getConnection();
$auditLog = new AuditLogModel($conn);
$auditLog->log(
$_SESSION['user']['user_id'],
'attachment_delete',
'ticket_attachments',
(string)$attachmentId,
[
'ticket_id' => $attachment['ticket_id'],
'filename' => $attachment['original_filename'],
'size' => $attachment['file_size']
]
);
ResponseHelper::success([], 'Attachment deleted successfully');
} catch (Exception $e) {
ResponseHelper::serverError('Failed to delete attachment');
}
+124
View File
@@ -0,0 +1,124 @@
<?php
/**
* API endpoint for deleting a comment
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
// Start output buffering
ob_start();
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Only allow POST or DELETE — reject GET to prevent CSRF bypass
$method = $_SERVER['REQUEST_METHOD'];
if ($method !== 'POST' && $method !== 'DELETE') {
http_response_code(405);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
exit;
}
// Check authentication via session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
$currentUser = $_SESSION['user'];
$userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false;
// Use centralized database connection
$conn = Database::getConnection();
// Get data - support both POST body and query params
$data = json_decode(file_get_contents('php://input'), true);
if (!$data || !isset($data['comment_id'])) {
// Also check POST params (no GET fallback — prevents CSRF bypass via URL)
if (isset($_POST['comment_id'])) {
$data = ['comment_id' => $_POST['comment_id']];
} else {
throw new Exception("Missing required field: comment_id");
}
}
$commentId = (int)$data['comment_id'];
// Initialize models
$commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn);
// Get comment before deletion for audit log and access check
$comment = $commentModel->getCommentById($commentId);
// Verify user can access the parent ticket
if ($comment) {
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($comment['ticket_id']);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
ob_end_clean();
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Access denied']);
exit;
}
}
// Delete comment
$result = $commentModel->deleteComment($commentId, $userId, $isAdmin);
// Log the deletion if successful
if ($result['success'] && $comment) {
$auditLog->log(
$userId,
'delete',
'comment',
(string)$commentId,
[
'ticket_id' => $comment['ticket_id'],
'comment_text_preview' => substr($comment['comment_text'], 0, 100)
]
);
}
// Discard any unexpected output
ob_end_clean();
header('Content-Type: application/json');
echo json_encode($result);
} catch (Exception $e) {
ob_end_clean();
error_log("Delete comment API error: " . $e->getMessage());
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'An internal error occurred'
]);
}
+140
View File
@@ -0,0 +1,140 @@
<?php
/**
* Download Attachment API
*
* Serves file downloads for ticket attachments
*/
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// Get attachment ID
$attachmentId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($attachmentId <= 0 || (string)$attachmentId !== (string)($_GET['id'] ?? '')) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Valid attachment ID is required']);
exit;
}
try {
$attachmentModel = new AttachmentModel(Database::getConnection());
// Get attachment details
$attachment = $attachmentModel->getAttachment($attachmentId);
if (!$attachment) {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Attachment not found']);
exit;
}
// Verify the associated ticket exists and user has access
$conn = Database::getConnection();
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($attachment['ticket_id']);
if (!$ticket) {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Associated ticket not found']);
exit;
}
// Check if user has access to this ticket based on visibility settings
if (!$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Access denied to this ticket']);
exit;
}
$conn->close();
// Build file path
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
// Security: Verify the resolved path is within the uploads directory (prevent path traversal)
$realUploadDir = realpath($uploadDir);
$realFilePath = realpath($filePath);
if ($realFilePath === false || $realUploadDir === false || strpos($realFilePath, $realUploadDir . DIRECTORY_SEPARATOR) !== 0) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Access denied']);
exit;
}
// Check if file exists
if (!file_exists($realFilePath)) {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'File not found on server']);
exit;
}
// Use the validated real path
$filePath = $realFilePath;
// Determine if we should display inline or force download
$inline = isset($_GET['inline']) && $_GET['inline'] === '1';
$inlineTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain'];
// Set headers
$disposition = ($inline && in_array($attachment['mime_type'], $inlineTypes)) ? 'inline' : 'attachment';
// Sanitize filename for Content-Disposition
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
header('Content-Type: ' . $attachment['mime_type']);
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
header('Content-Length: ' . $attachment['file_size']);
header('Cache-Control: private, max-age=3600');
header('X-Content-Type-Options: nosniff');
// Prevent PHP from timing out on large files
set_time_limit(0);
// Clear output buffer
if (ob_get_level()) {
ob_end_clean();
}
// Stream file
$handle = fopen($filePath, 'rb');
if ($handle === false) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Failed to open file']);
exit;
}
while (!feof($handle)) {
echo fread($handle, 8192);
flush();
}
fclose($handle);
exit;
} catch (Exception $e) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Failed to download attachment']);
exit;
}
+246
View File
@@ -0,0 +1,246 @@
<?php
/**
* Export Tickets API
*
* Exports tickets to CSV format with optional filtering
* Respects ticket visibility settings
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
try {
// Include required files
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
header('Content-Type: application/json');
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
$currentUser = $_SESSION['user'];
// Use centralized database connection
$conn = Database::getConnection();
// Get filter parameters
$status = isset($_GET['status']) ? $_GET['status'] : null;
$category = isset($_GET['category']) ? $_GET['category'] : null;
$type = isset($_GET['type']) ? $_GET['type'] : null;
$search = isset($_GET['search']) ? trim($_GET['search']) : null;
$format = isset($_GET['format']) ? $_GET['format'] : 'csv';
$ticketIds = isset($_GET['ticket_ids']) ? $_GET['ticket_ids'] : null;
$singleIdRaw = isset($_GET['ticket_id']) ? trim($_GET['ticket_id']) : null;
$singleId = ($singleIdRaw !== null && ctype_digit($singleIdRaw) && (int)$singleIdRaw > 0) ? $singleIdRaw : null;
// Initialize model
$ticketModel = new TicketModel($conn);
// Check if specific ticket IDs are provided
if ($ticketIds) {
// Parse and validate ticket IDs
$ticketIdArray = array_filter(array_map('trim', explode(',', $ticketIds)));
if (empty($ticketIdArray)) {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'No valid ticket IDs provided']);
exit;
}
// Get specific tickets by IDs
$allTickets = $ticketModel->getTicketsByIds($ticketIdArray);
// Filter tickets based on visibility - only export tickets the user can access
$tickets = [];
foreach ($allTickets as $ticket) {
if ($ticketModel->canUserAccessTicket($ticket, $currentUser)) {
$tickets[] = $ticket;
}
}
} else {
// Get all tickets with filters (no pagination for export)
// Pass $currentUser so visibility filtering is applied correctly
$result = $ticketModel->getAllTickets(1, 10000, $status, 'created_at', 'desc', $category, $type, $search, [], $currentUser);
$tickets = $result['tickets'];
}
if ($format === 'csv') {
// CSV Export
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="tickets_export_' . date('Y-m-d_His') . '.csv"');
header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache');
// Create output stream
$output = fopen('php://output', 'w');
// Add BOM for Excel UTF-8 compatibility
fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF));
// CSV Headers
$headers = [
'Ticket ID',
'Title',
'Status',
'Priority',
'Category',
'Type',
'Created By',
'Assigned To',
'Created At',
'Updated At',
'Description'
];
fputcsv($output, $headers);
// CSV Data
foreach ($tickets as $ticket) {
$row = [
$ticket['ticket_id'],
$ticket['title'],
$ticket['status'],
'P' . $ticket['priority'],
$ticket['category'],
$ticket['type'],
$ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System',
$ticket['assigned_display_name'] ?? $ticket['assigned_username'] ?? 'Unassigned',
$ticket['created_at'],
$ticket['updated_at'],
$ticket['description']
];
fputcsv($output, $row);
}
fclose($output);
exit;
} elseif ($format === 'json') {
// JSON Export
header('Content-Type: application/json');
header('Content-Disposition: attachment; filename="tickets_export_' . date('Y-m-d_His') . '.json"');
echo json_encode([
'exported_at' => date('c'),
'total_tickets' => count($tickets),
'tickets' => array_map(function ($t) {
return [
'ticket_id' => $t['ticket_id'],
'title' => $t['title'],
'status' => $t['status'],
'priority' => $t['priority'],
'category' => $t['category'],
'type' => $t['type'],
'description' => $t['description'],
'created_by' => $t['creator_display_name'] ?? $t['creator_username'],
'assigned_to' => $t['assigned_display_name'] ?? $t['assigned_username'],
'created_at' => $t['created_at'],
'updated_at' => $t['updated_at']
];
}, $tickets)
], JSON_PRETTY_PRINT);
exit;
} elseif ($format === 'full') {
// Full single-ticket export: ticket + all comments + audit timeline
if (!$singleId) {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'format=full requires a ticket_id parameter']);
exit;
}
$ticket = $ticketModel->getTicketById($singleId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
header('Content-Type: application/json');
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
$commentModel = new CommentModel($conn);
$auditLogModel = new AuditLogModel($conn);
// Load flat comment list (no threading nesting in export)
$rawComments = $commentModel->getCommentsByTicketId($ticket['ticket_id'], false);
$timeline = $auditLogModel->getTicketTimeline((string)$ticket['ticket_id']);
$comments = array_map(function ($c) {
return [
'comment_id' => $c['comment_id'],
'author' => $c['display_name'] ?? $c['username'] ?? 'Unknown',
'created_at' => $c['created_at'],
'updated_at' => $c['updated_at'] ?? null,
'comment_text' => $c['comment_text'],
'parent_comment_id' => $c['parent_comment_id'] ?? null,
];
}, $rawComments);
$timelineOut = array_map(function ($row) {
$details = $row['details'];
if (is_string($details)) {
$details = json_decode($details, true) ?? $details;
}
return [
'action' => $row['action_type'],
'entity' => $row['entity_type'],
'actor' => $row['display_name'] ?? $row['username'] ?? 'System',
'details' => $details,
'created_at' => $row['created_at'],
];
}, $timeline);
header('Content-Type: application/json');
header('Content-Disposition: attachment; filename="ticket_' . $ticket['ticket_id'] . '_full_' . date('Y-m-d_His') . '.json"');
header('Cache-Control: no-cache, must-revalidate');
echo json_encode([
'exported_at' => date('c'),
'ticket' => [
'ticket_id' => $ticket['ticket_id'],
'title' => $ticket['title'],
'status' => $ticket['status'],
'priority' => 'P' . $ticket['priority'],
'category' => $ticket['category'],
'type' => $ticket['type'],
'visibility' => $ticket['visibility'] ?? 'public',
'description' => $ticket['description'],
'created_by' => $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System',
'assigned_to' => $ticket['assigned_display_name'] ?? $ticket['assigned_username'] ?? 'Unassigned',
'created_at' => $ticket['created_at'],
'updated_at' => $ticket['updated_at'],
'closed_at' => $ticket['closed_at'] ?? null,
],
'comments' => $comments,
'comment_count' => count($comments),
'timeline' => $timelineOut,
], JSON_PRETTY_PRINT);
exit;
} else {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv, json, or full.']);
exit;
}
} catch (Exception $e) {
error_log("Export tickets API error: " . $e->getMessage());
header('Content-Type: application/json');
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'An internal error occurred'
]);
}
+120
View File
@@ -0,0 +1,120 @@
<?php
// API endpoint for generating API keys (Admin only)
error_reporting(E_ALL);
ini_set('display_errors', 0);
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
ob_start();
try {
// Load config
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
// Load models
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
// Check admin privileges
if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) {
throw new Exception("Admin privileges required");
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
throw new Exception("Invalid CSRF token");
}
}
// Only allow POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
throw new Exception("Method not allowed");
}
// Get request data
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
throw new Exception("Invalid request data");
}
$keyName = trim($input['key_name'] ?? '');
$expiresInDays = $input['expires_in_days'] ?? null;
if (empty($keyName)) {
throw new Exception("Key name is required");
}
if (strlen($keyName) > 100) {
throw new Exception("Key name must be 100 characters or less");
}
// Validate expires_in_days if provided
if ($expiresInDays !== null && $expiresInDays !== '') {
$expiresInDays = (int)$expiresInDays;
if ($expiresInDays < 1 || $expiresInDays > 3650) {
throw new Exception("Expiration must be between 1 and 3650 days");
}
} else {
$expiresInDays = null;
}
// Use centralized database connection
$conn = Database::getConnection();
// Generate API key
$apiKeyModel = new ApiKeyModel($conn);
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays);
if (!$result['success']) {
throw new Exception($result['error'] ?? "Failed to generate API key");
}
// Log the action
$auditLog = new AuditLogModel($conn);
$auditLog->log(
$_SESSION['user']['user_id'],
'create',
'api_key',
$result['key_id'],
['key_name' => $keyName, 'expires_in_days' => $expiresInDays]
);
// Clear output buffer
ob_end_clean();
// Return success with the plaintext key (shown only once)
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'api_key' => $result['api_key'],
'key_prefix' => $result['key_prefix'],
'key_id' => $result['key_id'],
'expires_at' => $result['expires_at']
]);
} catch (Exception $e) {
ob_end_clean();
error_log("Generate API key error: " . $e->getMessage());
header('Content-Type: application/json');
http_response_code(isset($conn) ? 400 : 500);
echo json_encode([
'success' => false,
'error' => 'An internal error occurred'
]);
}
+47
View File
@@ -0,0 +1,47 @@
<?php
/**
* Get Comments API
* Returns paginated comments for a ticket (used by "Load more" on ticket view)
*/
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
exit;
}
$ticketId = isset($_GET['ticket_id']) ? (int)$_GET['ticket_id'] : 0;
$offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0;
$limit = isset($_GET['limit']) ? min(100, max(1, (int)$_GET['limit'])) : 50;
if ($ticketId <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid ticket_id']);
exit;
}
$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;
}
$commentModel = new CommentModel($conn);
$total = $commentModel->getCommentCount($ticketId);
$comments = $commentModel->getCommentsByTicketId($ticketId, true, $limit, $offset);
echo json_encode([
'success' => true,
'comments' => $comments,
'total' => $total,
'offset' => $offset,
'limit' => $limit,
'has_more' => ($offset + $limit) < $total,
]);
+48 -41
View File
@@ -1,45 +1,52 @@
<?php <?php
session_start();
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/models/TemplateModel.php';
header('Content-Type: application/json'); /**
* Get Template API
* Returns a ticket template by ID
*/
// Check authentication require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { RateLimitMiddleware::apply('api');
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit; require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
} ErrorHandler::init();
// Get template ID from query parameter try {
$templateId = $_GET['template_id'] ?? null; if (session_status() === PHP_SESSION_NONE) {
session_start();
if (!$templateId) { }
echo json_encode(['success' => false, 'error' => 'Template ID required']); require_once dirname(__DIR__) . '/config/config.php';
exit; require_once dirname(__DIR__) . '/helpers/Database.php';
} require_once dirname(__DIR__) . '/models/TemplateModel.php';
// Create database connection header('Content-Type: application/json');
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'], // Check authentication
$GLOBALS['config']['DB_USER'], if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
$GLOBALS['config']['DB_PASS'], ErrorHandler::sendUnauthorizedError('Not authenticated');
$GLOBALS['config']['DB_NAME'] }
);
// Get template ID from query parameter
if ($conn->connect_error) { $templateId = isset($_GET['template_id']) ? (int)$_GET['template_id'] : 0;
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
exit; if ($templateId <= 0 || (string)$templateId !== (string)($_GET['template_id'] ?? '')) {
} ErrorHandler::sendValidationError(
['template_id' => 'Valid template ID required'],
// Get template 'Invalid request'
$templateModel = new TemplateModel($conn); );
$template = $templateModel->getTemplateById($templateId); }
$conn->close(); // Get template
$conn = Database::getConnection();
if ($template) { $templateModel = new TemplateModel($conn);
echo json_encode(['success' => true, 'template' => $template]); $template = $templateModel->getTemplateById($templateId);
} else {
echo json_encode(['success' => false, 'error' => 'Template not found']); if ($template) {
echo json_encode(['success' => true, 'template' => $template]);
} else {
ErrorHandler::sendNotFoundError('Template not found');
}
} catch (Exception $e) {
ErrorHandler::log($e->getMessage(), E_ERROR);
ErrorHandler::sendErrorResponse('Failed to retrieve template', 500, $e);
} }
+28 -29
View File
@@ -1,33 +1,32 @@
<?php <?php
session_start();
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
header('Content-Type: application/json'); /**
* Get Users API
* Returns list of users for @mentions autocomplete
*/
// Check authentication require_once __DIR__ . '/bootstrap.php';
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
echo json_encode(['success' => false, 'error' => 'Not authenticated']); try {
exit; // Get all users for mentions/assignment
$result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username");
if (!$result) {
throw new Exception("Failed to query users");
}
$users = [];
while ($row = $result->fetch_assoc()) {
$users[] = [
'user_id' => $row['user_id'],
'username' => $row['username'],
'display_name' => $row['display_name']
];
}
echo json_encode(['success' => true, 'users' => $users]);
} catch (Exception $e) {
error_log("Get users API error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
} }
// 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) {
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
exit;
}
// Get all users
$userModel = new UserModel($conn);
$users = $userModel->getAllUsers();
$conn->close();
echo json_encode(['success' => true, 'users' => $users]);
+111
View File
@@ -0,0 +1,111 @@
<?php
/**
* Health Check Endpoint
*
* Returns system health status for monitoring tools.
* Does not require authentication - suitable for load balancer health checks.
*
* Returns:
* - 200 OK: System is healthy
* - 503 Service Unavailable: System has issues
*/
// Don't apply rate limiting to health checks - they should always respond
header('Content-Type: application/json');
header('Cache-Control: no-cache, no-store, must-revalidate');
$startTime = microtime(true);
$checks = [];
$healthy = true;
// Check 1: Database connectivity
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
$conn = Database::getConnection();
// Quick query to verify connection is actually working
$result = $conn->query('SELECT 1');
if ($result && $result->fetch_row()) {
$checks['database'] = [
'status' => 'ok',
'message' => 'Connected'
];
} else {
$checks['database'] = [
'status' => 'error',
'message' => 'Query failed'
];
$healthy = false;
}
} catch (Exception $e) {
$checks['database'] = [
'status' => 'error',
'message' => 'Connection failed'
];
$healthy = false;
}
// Check 2: File system (uploads directory writable)
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
if (is_dir($uploadDir) && is_writable($uploadDir)) {
$checks['filesystem'] = [
'status' => 'ok',
'message' => 'Writable'
];
} else {
$checks['filesystem'] = [
'status' => 'warning',
'message' => 'Upload directory not writable'
];
// Don't mark as unhealthy - this might be intentional
}
// Check 3: Session storage
$sessionPath = session_save_path() ?: sys_get_temp_dir();
if (is_dir($sessionPath) && is_writable($sessionPath)) {
$checks['sessions'] = [
'status' => 'ok',
'message' => 'Writable'
];
} else {
$checks['sessions'] = [
'status' => 'error',
'message' => 'Session storage not writable'
];
$healthy = false;
}
// Check 4: Rate limit storage
$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
if (!is_dir($rateLimitDir)) {
@mkdir($rateLimitDir, 0755, true);
}
if (is_dir($rateLimitDir) && is_writable($rateLimitDir)) {
$checks['rate_limit'] = [
'status' => 'ok',
'message' => 'Writable'
];
} else {
$checks['rate_limit'] = [
'status' => 'warning',
'message' => 'Rate limit storage not writable'
];
}
// Calculate response time
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
// Set status code
http_response_code($healthy ? 200 : 503);
// Return response
echo json_encode([
'status' => $healthy ? 'healthy' : 'unhealthy',
'timestamp' => date('c'),
'response_time_ms' => $responseTime,
'checks' => $checks,
'version' => '1.0.0'
], JSON_PRETTY_PRINT);
+175
View File
@@ -0,0 +1,175 @@
<?php
/**
* Recurring Tickets Management API
* CRUD operations for recurring_tickets table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
// Check authentication
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// Check admin privileges
if (!$_SESSION['user']['is_admin']) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
exit;
}
$currentUserId = $_SESSION['user']['user_id'];
// CSRF Protection for write operations
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
// Use centralized database connection
$conn = Database::getConnection();
header('Content-Type: application/json');
$model = new RecurringTicketModel($conn);
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
$action = isset($_GET['action']) ? $_GET['action'] : null;
switch ($method) {
case 'GET':
if ($id) {
$recurring = $model->getById($id);
echo json_encode(['success' => (bool)$recurring, 'recurring' => $recurring]);
} else {
$all = $model->getAll(true);
echo json_encode(['success' => true, 'recurring_tickets' => $all]);
}
break;
case 'POST':
if ($action === 'toggle' && $id) {
$result = $model->toggleActive($id);
echo json_encode($result);
} else {
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data) || empty($data['schedule_type']) || empty($data['title_template'])) {
echo json_encode(['success' => false, 'error' => 'schedule_type and title_template are required']);
exit;
}
// Calculate next run time
$nextRun = calculateNextRun(
$data['schedule_type'],
$data['schedule_day'] ?? null,
$data['schedule_time'] ?? '09:00'
);
$data['next_run_at'] = $nextRun;
$data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1;
$data['created_by'] = $currentUserId;
$result = $model->create($data);
echo json_encode($result);
}
break;
case 'PUT':
if (!$id) {
echo json_encode(['success' => false, 'error' => 'ID required']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data) || empty($data['schedule_type'])) {
echo json_encode(['success' => false, 'error' => 'Invalid request data']);
exit;
}
// Recalculate next run time if schedule changed
$nextRun = calculateNextRun(
$data['schedule_type'],
$data['schedule_day'] ?? null,
$data['schedule_time'] ?? '09:00'
);
$data['next_run_at'] = $nextRun;
$data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1;
$result = $model->update($id, $data);
echo json_encode($result);
break;
case 'DELETE':
if (!$id) {
echo json_encode(['success' => false, 'error' => 'ID required']);
exit;
}
$result = $model->delete($id);
echo json_encode($result);
break;
default:
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
}
} catch (Exception $e) {
error_log("Recurring tickets API error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
}
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime)
{
$now = new DateTime();
$time = $scheduleTime ?: '09:00';
switch ($scheduleType) {
case 'daily':
$next = new DateTime('tomorrow ' . $time);
break;
case 'weekly':
$days = [1 => 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
$dayName = $days[(int)$scheduleDay] ?? 'Monday';
$next = new DateTime("next {$dayName} " . $time);
break;
case 'monthly':
$day = max(1, min(31, (int)$scheduleDay));
$next = new DateTime();
$next->modify('first day of next month');
// Clamp to last day of target month (handles Feb, 30-day months)
$daysInMonth = (int)$next->format('t');
$day = min($day, $daysInMonth);
$next->setDate((int)$next->format('Y'), (int)$next->format('m'), $day);
$parts = explode(':', $time . ':00'); // ensure at least H:M
$next->setTime((int)$parts[0], (int)$parts[1], 0);
break;
default:
$next = new DateTime('tomorrow ' . $time);
}
return $next->format('Y-m-d H:i:s');
}
+188
View File
@@ -0,0 +1,188 @@
<?php
/**
* Template Management API
* CRUD operations for ticket_templates table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
// Check authentication
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// Check admin privileges for write operations
if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !$_SESSION['user']['is_admin']) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
exit;
}
// CSRF Protection for write operations
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
// Use centralized database connection
$conn = Database::getConnection();
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
switch ($method) {
case 'GET':
if ($id) {
// Get single template
$stmt = $conn->prepare("SELECT * FROM ticket_templates WHERE template_id = ?");
$stmt->bind_param('i', $id);
$stmt->execute();
$result = $stmt->get_result();
$template = $result->fetch_assoc();
$stmt->close();
echo json_encode(['success' => true, 'template' => $template]);
} else {
// Get all templates
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
$templates = [];
while ($row = $result->fetch_assoc()) {
$templates[] = $row;
}
echo json_encode(['success' => true, 'templates' => $templates]);
}
break;
case 'POST':
$data = json_decode(file_get_contents('php://input'), true);
// Validate required fields and lengths
$templateName = trim($data['template_name'] ?? '');
$titleTemplate = trim($data['title_template'] ?? '');
if (!$templateName || mb_strlen($templateName) > 100) {
echo json_encode(['success' => false, 'error' => 'Template name is required (max 100 chars)']);
exit;
}
if (!$titleTemplate || mb_strlen($titleTemplate) > 255) {
echo json_encode(['success' => false, 'error' => 'Title template is required (max 255 chars)']);
exit;
}
$allowedCategories = ['General','Hardware','Software','Network','Security'];
$allowedTypes = ['Issue','Maintenance','Install','Task','Upgrade','Problem'];
$category = in_array($data['category'] ?? '', $allowedCategories) ? $data['category'] : 'General';
$type = in_array($data['type'] ?? '', $allowedTypes) ? $data['type'] : 'Issue';
$priority = max(1, min(5, (int)($data['default_priority'] ?? 4)));
$isActive = $data['is_active'] ? 1 : 0;
$description = mb_substr($data['description_template'] ?? '', 0, 65535);
$stmt = $conn->prepare("INSERT INTO ticket_templates
(template_name, title_template, description_template, category, type, default_priority, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?)");
$stmt->bind_param(
'sssssii',
$templateName,
$titleTemplate,
$description,
$category,
$type,
$priority,
$isActive
);
if ($stmt->execute()) {
echo json_encode(['success' => true, 'template_id' => $conn->insert_id]);
} else {
error_log("Template creation failed: " . $stmt->error);
echo json_encode(['success' => false, 'error' => 'Failed to create template']);
}
$stmt->close();
break;
case 'PUT':
if (!$id) {
echo json_encode(['success' => false, 'error' => 'ID required']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true);
// Validate required fields and lengths
$templateName = trim($data['template_name'] ?? '');
$titleTemplate = trim($data['title_template'] ?? '');
if (!$templateName || mb_strlen($templateName) > 100) {
echo json_encode(['success' => false, 'error' => 'Template name is required (max 100 chars)']);
exit;
}
if (!$titleTemplate || mb_strlen($titleTemplate) > 255) {
echo json_encode(['success' => false, 'error' => 'Title template is required (max 255 chars)']);
exit;
}
$allowedCategories = ['General','Hardware','Software','Network','Security'];
$allowedTypes = ['Issue','Maintenance','Install','Task','Upgrade','Problem'];
$category = in_array($data['category'] ?? '', $allowedCategories) ? $data['category'] : 'General';
$type = in_array($data['type'] ?? '', $allowedTypes) ? $data['type'] : 'Issue';
$priority = max(1, min(5, (int)($data['default_priority'] ?? 4)));
$isActive = $data['is_active'] ? 1 : 0;
$description = mb_substr($data['description_template'] ?? '', 0, 65535);
$stmt = $conn->prepare("UPDATE ticket_templates SET
template_name = ?, title_template = ?, description_template = ?,
category = ?, type = ?, default_priority = ?, is_active = ?
WHERE template_id = ?");
$stmt->bind_param(
'sssssiii',
$templateName,
$titleTemplate,
$description,
$category,
$type,
$priority,
$isActive,
$id
);
echo json_encode(['success' => $stmt->execute()]);
$stmt->close();
break;
case 'DELETE':
if (!$id) {
echo json_encode(['success' => false, 'error' => 'ID required']);
exit;
}
$stmt = $conn->prepare("DELETE FROM ticket_templates WHERE template_id = ?");
$stmt->bind_param('i', $id);
echo json_encode(['success' => $stmt->execute()]);
$stmt->close();
break;
default:
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
}
} catch (Exception $e) {
error_log("Template API error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
}
+198
View File
@@ -0,0 +1,198 @@
<?php
/**
* Workflow/Status Transitions Management API
* CRUD operations for status_transitions table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
RateLimitMiddleware::apply('api');
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// Check admin privileges
if (!$_SESSION['user']['is_admin']) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
exit;
}
// CSRF Protection for write operations
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
// Use centralized database connection
$conn = Database::getConnection();
// Initialize audit log
$auditLog = new AuditLogModel($conn);
$userId = $_SESSION['user']['user_id'];
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
switch ($method) {
case 'GET':
if ($id) {
// Get single transition
$stmt = $conn->prepare("SELECT * FROM status_transitions WHERE transition_id = ?");
$stmt->bind_param('i', $id);
$stmt->execute();
$result = $stmt->get_result();
$transition = $result->fetch_assoc();
$stmt->close();
echo json_encode(['success' => true, 'transition' => $transition]);
} else {
// Get all transitions
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
$transitions = [];
while ($row = $result->fetch_assoc()) {
$transitions[] = $row;
}
echo json_encode(['success' => true, 'transitions' => $transitions]);
}
break;
case 'POST':
$data = json_decode(file_get_contents('php://input'), true);
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']);
exit;
}
$stmt = $conn->prepare("INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin, is_active)
VALUES (?, ?, ?, ?, ?)");
$wf_from = $data['from_status'];
$wf_to = $data['to_status'];
$wf_comment = (int)($data['requires_comment'] ?? 0);
$wf_admin = (int)($data['requires_admin'] ?? 0);
$wf_active = (int)($data['is_active'] ?? 1);
$stmt->bind_param('ssiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active);
if ($stmt->execute()) {
$transitionId = $conn->insert_id;
WorkflowModel::clearCache(); // Clear workflow cache
// Audit log: workflow transition created
$auditLog->log($userId, 'create', 'workflow_transition', (string)$transitionId, [
'from_status' => $data['from_status'],
'to_status' => $data['to_status'],
'requires_comment' => $data['requires_comment'] ?? 0,
'requires_admin' => $data['requires_admin'] ?? 0
]);
echo json_encode(['success' => true, 'transition_id' => $transitionId]);
} else {
error_log("Workflow creation failed: " . $stmt->error);
echo json_encode(['success' => false, 'error' => 'Failed to create workflow transition']);
}
$stmt->close();
break;
case 'PUT':
if (!$id) {
echo json_encode(['success' => false, 'error' => 'ID required']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true);
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']);
exit;
}
$stmt = $conn->prepare("UPDATE status_transitions SET
from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ?
WHERE transition_id = ?");
$wf_from = $data['from_status'];
$wf_to = $data['to_status'];
$wf_comment = (int)($data['requires_comment'] ?? 0);
$wf_admin = (int)($data['requires_admin'] ?? 0);
$wf_active = (int)($data['is_active'] ?? 1);
$stmt->bind_param('ssiiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active, $id);
$success = $stmt->execute();
if ($success) {
WorkflowModel::clearCache(); // Clear workflow cache
// Audit log: workflow transition updated
$auditLog->log($userId, 'update', 'workflow_transition', (string)$id, [
'from_status' => $data['from_status'],
'to_status' => $data['to_status'],
'requires_comment' => $data['requires_comment'] ?? 0,
'requires_admin' => $data['requires_admin'] ?? 0
]);
}
echo json_encode(['success' => $success]);
$stmt->close();
break;
case 'DELETE':
if (!$id) {
echo json_encode(['success' => false, 'error' => 'ID required']);
exit;
}
// Get transition details before deletion for audit log
$getStmt = $conn->prepare("SELECT from_status, to_status FROM status_transitions WHERE transition_id = ?");
$getStmt->bind_param('i', $id);
$getStmt->execute();
$getResult = $getStmt->get_result();
$transitionData = $getResult->fetch_assoc();
$getStmt->close();
$stmt = $conn->prepare("DELETE FROM status_transitions WHERE transition_id = ?");
$stmt->bind_param('i', $id);
$success = $stmt->execute();
if ($success) {
WorkflowModel::clearCache(); // Clear workflow cache
// Audit log: workflow transition deleted
$auditLog->log($userId, 'delete', 'workflow_transition', (string)$id, [
'from_status' => $transitionData['from_status'] ?? 'unknown',
'to_status' => $transitionData['to_status'] ?? 'unknown'
]);
}
echo json_encode(['success' => $success]);
$stmt->close();
break;
default:
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
}
} catch (Exception $e) {
error_log("Workflow API error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
}
+214
View File
@@ -0,0 +1,214 @@
<?php
/**
* Notifications API
*
* GET → returns recent notifications for the current user (last 7 days, max 30)
* POST { action: 'mark_read', log_id?: N } → updates last_seen timestamp in user_preferences
*
* Notifications are derived from audit_log:
* - Tickets assigned to me (action_type='assign', details.assigned_to = userId)
* - Comments on my tickets (action_type='comment', ticket assigned_to/created_by = userId)
* - Status changes on watched (via ticket_watchers)
* - @mentions in comments (action_type='comment', details.mentions[] contains username)
*/
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
$prefsModel = new UserPreferencesModel($conn);
// ── POST: mark all read (update last_seen timestamp) ──────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode(file_get_contents('php://input'), true) ?? [];
if (($data['action'] ?? '') === 'mark_read') {
$prefsModel->setPreference($userId, 'notif_last_seen', date('Y-m-d H:i:s'));
apiRespond(['success' => true]);
} else {
http_response_code(400);
apiRespond(['success' => false, 'error' => 'Unknown action']);
}
exit;
}
// ── GET: fetch notifications ──────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
apiRespond(['success' => false, 'error' => 'Method not allowed']);
exit;
}
// Get last_seen timestamp (when user last marked all read)
$prefs = $prefsModel->getUserPreferences($userId);
$lastSeen = $prefs['notif_last_seen'] ?? null;
// Username for @mention detection
$myUsername = $currentUser['username'] ?? '';
// Query 1: Tickets assigned to me (events from other users)
$assignSql = "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 = 'assign'
AND al.entity_type = 'ticket'
AND al.user_id != ?
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
AND al.details LIKE ?
ORDER BY al.created_at DESC
LIMIT 15";
$assignLike = '%"assigned_to":' . $userId . '%';
$stmt = $conn->prepare($assignSql);
$stmt->bind_param('is', $userId, $assignLike);
$stmt->execute();
$assignRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
// Query 2: Comments on tickets I own or watch (events from other users)
// Comments are logged as action_type='create', entity_type='comment', ticket_id stored in details JSON.
// Avoid JSON_EXTRACT (not universally supported) — fetch recent entries then filter in PHP.
// Step A: ticket IDs the current user owns or watches
$myTicketIds = [];
$myTicketsSql = "SELECT DISTINCT ticket_id FROM tickets WHERE assigned_to = ? OR created_by = ?";
$stmt = $conn->prepare($myTicketsSql);
$stmt->bind_param('ii', $userId, $userId);
$stmt->execute();
$mtResult = $stmt->get_result();
while ($mtRow = $mtResult->fetch_assoc()) {
$myTicketIds[(int)$mtRow['ticket_id']] = true;
$myTicketIds[$mtRow['ticket_id']] = true;
}
$stmt->close();
$watchedSql = "SELECT ticket_id FROM ticket_watchers WHERE user_id = ?";
$stmt = $conn->prepare($watchedSql);
$stmt->bind_param('i', $userId);
$stmt->execute();
$wResult = $stmt->get_result();
while ($wRow = $wResult->fetch_assoc()) {
$myTicketIds[(int)$wRow['ticket_id']] = true;
$myTicketIds[$wRow['ticket_id']] = true;
}
$stmt->close();
// Step B: fetch recent comment audit events not by the current user
$commentSql = "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 IN ('comment', 'create')
AND al.entity_type = 'comment'
AND al.user_id != ?
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
ORDER BY al.created_at DESC
LIMIT 50";
$stmt = $conn->prepare($commentSql);
$stmt->bind_param('i', $userId);
$stmt->execute();
$rawCommentRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
// Step C: filter to only comments on tickets the current user owns/watches
$commentRows = [];
foreach ($rawCommentRows as $rawRow) {
$d = json_decode($rawRow['details'] ?? '{}', true) ?? [];
$tidRaw = $d['ticket_id'] ?? 0;
$tid = (int)$tidRaw;
if ($tid > 0 && (isset($myTicketIds[$tid]) || isset($myTicketIds[$tidRaw]))) {
$commentRows[] = $rawRow;
if (count($commentRows) >= 15) {
break;
}
}
}
// Query 3: Status changes on watched tickets (from other users)
$statusSql = "SELECT DISTINCT
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
INNER JOIN ticket_watchers tw ON tw.ticket_id = CAST(al.entity_id AS UNSIGNED) AND tw.user_id = ?
WHERE al.action_type = 'update'
AND al.entity_type = 'ticket'
AND al.user_id != ?
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
AND al.details LIKE '%\"status\":%'
ORDER BY al.created_at DESC
LIMIT 10";
$stmt = $conn->prepare($statusSql);
$stmt->bind_param('ii', $userId, $userId);
$stmt->execute();
$statusRows = $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) {
$id = (int)$row['log_id'];
if (isset($seen[$id])) {
continue;
}
$seen[$id] = true;
$all[] = $row;
}
usort($all, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
$all = array_slice($all, 0, 30);
// Format for response
$notifications = [];
foreach ($all as $row) {
$details = json_decode($row['details'] ?? '{}', true) ?? [];
// Comment rows: entity_id is the comment_id; real ticket_id is in details
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
? 'comment'
: $row['action_type'];
$ticketId = ($actionType === 'comment')
? ($details['ticket_id'] ?? 0)
: $row['entity_id'];
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
// Build human-readable title
$title = match ($actionType) {
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
'update' => (function () use ($row, $details, $ticketId) {
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}}
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
$to = $details['status']['to'] ?? ($details['new_value'] ?? '?');
return "{$row['actor_name']} changed status on #{$ticketId}: {$from}{$to}";
})(),
default => "{$row['actor_name']} updated ticket #{$ticketId}",
};
$ticketTitle = $details['title'] ?? null;
if ($ticketTitle) {
$title .= ' — ' . mb_substr($ticketTitle, 0, 40) . (mb_strlen($ticketTitle) > 40 ? '…' : '');
}
$notifications[] = [
'log_id' => (int)$row['log_id'],
'ticket_id' => $ticketId,
'title' => $title,
'created_at' => $row['created_at'],
'is_read' => $isRead,
'action' => $actionType,
'url' => "/ticket/{$ticketId}",
];
}
$unreadCount = count(array_filter($notifications, fn($n) => !$n['is_read']));
apiRespond([
'success' => true,
'notifications' => $notifications,
'unread_count' => $unreadCount,
'last_seen' => $lastSeen,
]);
+113
View File
@@ -0,0 +1,113 @@
<?php
// API endpoint for revoking API keys (Admin only)
error_reporting(E_ALL);
ini_set('display_errors', 0);
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
ob_start();
try {
// Load config
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
// Load models
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
// Check admin privileges
if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) {
throw new Exception("Admin privileges required");
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
throw new Exception("Invalid CSRF token");
}
}
// Only allow POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
throw new Exception("Method not allowed");
}
// Get request data
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
throw new Exception("Invalid request data");
}
$keyId = (int)($input['key_id'] ?? 0);
if ($keyId <= 0) {
throw new Exception("Valid key ID is required");
}
// Use centralized database connection
$conn = Database::getConnection();
// Get key info for audit log
$apiKeyModel = new ApiKeyModel($conn);
$keyInfo = $apiKeyModel->getKeyById($keyId);
if (!$keyInfo) {
throw new Exception("API key not found");
}
if (!$keyInfo['is_active']) {
throw new Exception("API key is already revoked");
}
// Revoke the key
$success = $apiKeyModel->revokeKey($keyId);
if (!$success) {
throw new Exception("Failed to revoke API key");
}
// Log the action
$auditLog = new AuditLogModel($conn);
$auditLog->log(
$_SESSION['user']['user_id'],
'revoke',
'api_key',
$keyId,
['key_name' => $keyInfo['key_name'], 'key_prefix' => $keyInfo['key_prefix']]
);
// Clear output buffer
ob_end_clean();
// Return success
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'message' => 'API key revoked successfully'
]);
} catch (Exception $e) {
ob_end_clean();
error_log("Revoke API key error: " . $e->getMessage());
header('Content-Type: application/json');
http_response_code(isset($conn) ? 400 : 500);
echo json_encode([
'success' => false,
'error' => 'An internal error occurred'
]);
}
+22 -70
View File
@@ -1,49 +1,13 @@
<?php <?php
/** /**
* Saved Filters API Endpoint * Saved Filters API Endpoint
* Handles GET (fetch filters), POST (create filter), PUT (update filter), DELETE (delete filter) * Handles GET (fetch filters), POST (create filter), PUT (update filter), DELETE (delete filter)
*/ */
require_once dirname(__DIR__) . '/config/config.php'; require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/SavedFiltersModel.php'; require_once dirname(__DIR__) . '/models/SavedFiltersModel.php';
session_start();
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit;
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
$userId = $_SESSION['user']['user_id'];
// 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) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
exit;
}
$filtersModel = new SavedFiltersModel($conn); $filtersModel = new SavedFiltersModel($conn);
// GET - Fetch all saved filters or a specific filter // GET - Fetch all saved filters or a specific filter
@@ -54,25 +18,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$filter = $filtersModel->getFilter($filterId, $userId); $filter = $filtersModel->getFilter($filterId, $userId);
if ($filter) { if ($filter) {
echo json_encode(['success' => true, 'filter' => $filter]); apiRespond(['success' => true, 'filter' => $filter]);
} else { } else {
http_response_code(404); http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Filter not found']); apiRespond(['success' => false, 'error' => 'Filter not found']);
} }
} else if (isset($_GET['default'])) { } elseif (isset($_GET['default'])) {
// Get default filter // Get default filter
$filter = $filtersModel->getDefaultFilter($userId); $filter = $filtersModel->getDefaultFilter($userId);
echo json_encode(['success' => true, 'filter' => $filter]); apiRespond(['success' => true, 'filter' => $filter]);
} else { } else {
// Get all filters // Get all filters
$filters = $filtersModel->getUserFilters($userId); $filters = $filtersModel->getUserFilters($userId);
echo json_encode(['success' => true, 'filters' => $filters]); apiRespond(['success' => true, 'filters' => $filters]);
} }
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to fetch filters']); apiRespond(['success' => false, 'error' => 'Failed to fetch filters']);
} }
$conn->close();
exit; exit;
} }
@@ -82,8 +45,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) { if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']); apiRespond(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
$conn->close();
exit; exit;
} }
@@ -94,19 +56,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validate filter name // Validate filter name
if (empty($filterName) || strlen($filterName) > 100) { if (empty($filterName) || strlen($filterName) > 100) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid filter name']); apiRespond(['success' => false, 'error' => 'Invalid filter name']);
$conn->close();
exit; exit;
} }
try { try {
$result = $filtersModel->saveFilter($userId, $filterName, $filterCriteria, $isDefault); $result = $filtersModel->saveFilter($userId, $filterName, $filterCriteria, $isDefault);
echo json_encode($result); apiRespond($result);
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save filter']); apiRespond(['success' => false, 'error' => 'Failed to save filter']);
} }
$conn->close();
exit; exit;
} }
@@ -116,8 +76,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
if (!isset($data['filter_id'])) { if (!isset($data['filter_id'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_id']); apiRespond(['success' => false, 'error' => 'Missing filter_id']);
$conn->close();
exit; exit;
} }
@@ -127,20 +86,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
if (isset($data['set_default']) && $data['set_default'] === true) { if (isset($data['set_default']) && $data['set_default'] === true) {
try { try {
$result = $filtersModel->setDefaultFilter($filterId, $userId); $result = $filtersModel->setDefaultFilter($filterId, $userId);
echo json_encode($result); apiRespond($result);
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to set default filter']); apiRespond(['success' => false, 'error' => 'Failed to set default filter']);
} }
$conn->close();
exit; exit;
} }
// Handle full filter update // Handle full filter update
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) { if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']); apiRespond(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
$conn->close();
exit; exit;
} }
@@ -150,12 +107,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
try { try {
$result = $filtersModel->updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault); $result = $filtersModel->updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault);
echo json_encode($result); apiRespond($result);
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to update filter']); apiRespond(['success' => false, 'error' => 'Failed to update filter']);
} }
$conn->close();
exit; exit;
} }
@@ -165,8 +121,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
if (!isset($data['filter_id'])) { if (!isset($data['filter_id'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_id']); apiRespond(['success' => false, 'error' => 'Missing filter_id']);
$conn->close();
exit; exit;
} }
@@ -174,17 +129,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
try { try {
$result = $filtersModel->deleteFilter($filterId, $userId); $result = $filtersModel->deleteFilter($filterId, $userId);
echo json_encode($result); apiRespond($result);
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to delete filter']); apiRespond(['success' => false, 'error' => 'Failed to delete filter']);
} }
$conn->close();
exit; exit;
} }
// Method not allowed // Method not allowed
http_response_code(405); http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']); apiRespond(['success' => false, 'error' => 'Method not allowed']);
$conn->close();
?>
+263
View File
@@ -0,0 +1,263 @@
<?php
/**
* Ticket Dependencies API
*/
// Immediately set JSON header and start output buffering
ob_start();
header('Content-Type: application/json');
// Register shutdown function to catch fatal errors
register_shutdown_function(function () {
$error = error_get_last();
if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
// Log detailed error server-side
error_log('Fatal error in ticket_dependencies.php: ' . $error['message'] . ' in ' . $error['file'] . ':' . $error['line']);
ob_end_clean();
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'A server error occurred'
]);
}
});
ini_set('display_errors', 0);
error_reporting(E_ALL);
// Custom error handler
set_error_handler(function ($errno, $errstr, $errfile, $errline) {
// Log detailed error server-side
error_log("PHP Error in ticket_dependencies.php: $errstr in $errfile:$errline");
ob_end_clean();
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'A server error occurred'
]);
exit;
});
// Custom exception handler
set_exception_handler(function ($e) {
// Log detailed error server-side
error_log('Exception in ticket_dependencies.php: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
ob_end_clean();
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'A server error occurred'
]);
exit;
});
// Apply rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
// Ensure session is started
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/DependencyModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
ResponseHelper::unauthorized();
}
$userId = $_SESSION['user']['user_id'];
$currentUser = $_SESSION['user'];
// CSRF Protection for POST/DELETE
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ResponseHelper::forbidden('Invalid CSRF token');
}
}
// Use centralized database connection
$conn = Database::getConnection();
// Check if ticket_dependencies table exists
$tableCheck = $conn->query("SHOW TABLES LIKE 'ticket_dependencies'");
if ($tableCheck->num_rows === 0) {
ResponseHelper::serverError('Ticket dependencies feature not available. The ticket_dependencies table does not exist. Please run the migration.');
}
try {
$dependencyModel = new DependencyModel($conn);
$auditLog = new AuditLogModel($conn);
$ticketModel = new TicketModel($conn);
} catch (Exception $e) {
error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage());
ResponseHelper::serverError('Failed to initialize required components');
}
$method = $_SERVER['REQUEST_METHOD'];
try {
switch ($method) {
case 'GET':
// Get dependencies for a ticket
$ticketId = $_GET['ticket_id'] ?? null;
if (!$ticketId) {
ResponseHelper::error('Ticket ID required');
}
// Verify user can access this ticket
$ticket = $ticketModel->getTicketById((int)$ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
try {
$dependencies = $dependencyModel->getDependencies($ticketId);
$dependents = $dependencyModel->getDependentTickets($ticketId);
} catch (Exception $e) {
error_log('Query error in ticket_dependencies.php GET: ' . $e->getMessage());
ResponseHelper::serverError('Failed to retrieve dependencies');
}
ResponseHelper::success([
'dependencies' => $dependencies,
'dependents' => $dependents
]);
break;
case 'POST':
// Add a new dependency
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
ResponseHelper::error('Invalid JSON');
}
$ticketId = $data['ticket_id'] ?? null;
$dependsOnId = $data['depends_on_id'] ?? null;
$type = $data['dependency_type'] ?? 'blocks';
if (!$ticketId || !$dependsOnId) {
ResponseHelper::error('Both ticket_id and depends_on_id are required');
}
// Verify user can access both tickets before creating dependency
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
$tgtTicket = $ticketModel->getTicketById((int)$dependsOnId);
if (!$tgtTicket || !$ticketModel->canUserAccessTicket($tgtTicket, $currentUser)) {
ResponseHelper::notFound('Target ticket not found');
}
$result = $dependencyModel->addDependency($ticketId, $dependsOnId, $type, $userId);
if ($result['success']) {
// Log to audit
$auditLog->log($userId, 'create', 'dependency', (string)$result['dependency_id'], [
'ticket_id' => $ticketId,
'depends_on_id' => $dependsOnId,
'type' => $type
]);
ResponseHelper::created($result);
} else {
ResponseHelper::error($result['error']);
}
break;
case 'DELETE':
// Remove a dependency
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
ResponseHelper::error('Invalid JSON');
}
$dependencyId = $data['dependency_id'] ?? null;
// Alternative: delete by ticket IDs
if (!$dependencyId && isset($data['ticket_id']) && isset($data['depends_on_id'])) {
$ticketId = $data['ticket_id'];
$dependsOnId = $data['depends_on_id'];
$type = $data['dependency_type'] ?? 'blocks';
// Validate dependency type
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
if (!in_array($type, $validTypes, true)) {
ResponseHelper::error('Invalid dependency type');
}
// Verify user can access the source ticket
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
$result = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type);
if ($result) {
$auditLog->log($userId, 'delete', 'dependency', null, [
'ticket_id' => $ticketId,
'depends_on_id' => $dependsOnId,
'type' => $type
]);
ResponseHelper::success([], 'Dependency removed');
} else {
ResponseHelper::error('Failed to remove dependency');
}
} elseif ($dependencyId) {
// Look up dependency to verify ticket access before deletion
$depLookupSql = "SELECT ticket_id FROM ticket_dependencies WHERE dependency_id = ?";
$depLookupStmt = $conn->prepare($depLookupSql);
$depLookupStmt->bind_param("i", $dependencyId);
$depLookupStmt->execute();
$depRow = $depLookupStmt->get_result()->fetch_assoc();
$depLookupStmt->close();
if (!$depRow) {
ResponseHelper::notFound('Dependency not found');
}
$depTicket = $ticketModel->getTicketById((int)$depRow['ticket_id']);
if (!$depTicket || !$ticketModel->canUserAccessTicket($depTicket, $currentUser)) {
ResponseHelper::forbidden('Access denied');
}
$result = $dependencyModel->removeDependency($dependencyId);
if ($result) {
$auditLog->log($userId, 'delete', 'dependency', (string)$dependencyId);
ResponseHelper::success([], 'Dependency removed');
} else {
ResponseHelper::error('Failed to remove dependency');
}
} else {
ResponseHelper::error('Dependency ID or ticket IDs required');
}
break;
default:
ResponseHelper::error('Method not allowed', 405);
}
} catch (Exception $e) {
// Log detailed error server-side
error_log('Ticket dependencies API error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
ResponseHelper::serverError('An error occurred while processing the dependency request');
};
+113
View File
@@ -0,0 +1,113 @@
<?php
/**
* API endpoint for updating a comment
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
// Start output buffering
ob_start();
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
$currentUser = $_SESSION['user'];
$userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false;
// Use centralized database connection
$conn = Database::getConnection();
// Get POST/PUT data
$data = json_decode(file_get_contents('php://input'), true);
if (!$data || !isset($data['comment_id']) || !isset($data['comment_text'])) {
throw new Exception("Missing required fields: comment_id, comment_text");
}
$commentId = (int)$data['comment_id'];
$commentText = trim($data['comment_text']);
$markdownEnabled = isset($data['markdown_enabled']) && $data['markdown_enabled'];
if (empty($commentText)) {
throw new Exception("Comment text cannot be empty");
}
// Initialize models
$commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn);
// Verify user can access the parent ticket
$comment = $commentModel->getCommentById($commentId);
if ($comment) {
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($comment['ticket_id']);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
ob_end_clean();
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Access denied']);
exit;
}
}
// Update comment
$result = $commentModel->updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin);
// Log the update if successful
if ($result['success']) {
$auditLog->log(
$userId,
'update',
'comment',
(string)$commentId,
['comment_text_preview' => substr($commentText, 0, 100)]
);
}
// Discard any unexpected output
ob_end_clean();
header('Content-Type: application/json');
echo json_encode($result);
} catch (Exception $e) {
ob_end_clean();
error_log("Update comment API error: " . $e->getMessage());
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'An internal error occurred'
]);
}
+149 -200
View File
@@ -1,61 +1,38 @@
<?php <?php
// Enable error reporting for debugging // Enable error reporting for debugging
error_reporting(E_ALL); error_reporting(E_ALL);
ini_set('display_errors', 0); // Don't display errors in the response ini_set('display_errors', 0); // Don't display errors in the response
// Define a debug log function // Apply rate limiting
function debug_log($message) { require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
file_put_contents('/tmp/api_debug.log', date('Y-m-d H:i:s') . " - $message\n", FILE_APPEND); RateLimitMiddleware::apply('api');
}
// Start output buffering to capture any errors // Start output buffering to capture any errors
ob_start(); ob_start();
try { try {
debug_log("Script started");
// Load config // Load config
$configPath = dirname(__DIR__) . '/config/config.php'; $configPath = dirname(__DIR__) . '/config/config.php';
debug_log("Loading config from: $configPath");
require_once $configPath; require_once $configPath;
debug_log("Config loaded successfully"); require_once dirname(__DIR__) . '/helpers/Database.php';
// Load environment variables (for Discord webhook)
$envPath = dirname(__DIR__) . '/.env';
$envVars = [];
if (file_exists($envPath)) {
$lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
list($key, $value) = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
// Remove surrounding quotes if present
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
$value = substr($value, 1, -1);
}
$envVars[$key] = $value;
}
}
debug_log("Environment variables loaded");
}
// Load models directly with absolute paths // Load models directly with absolute paths
$ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php'; $ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php';
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php'; $commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php'; $auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
$workflowModelPath = dirname(__DIR__) . '/models/WorkflowModel.php'; $workflowModelPath = dirname(__DIR__) . '/models/WorkflowModel.php';
debug_log("Loading models from: $ticketModelPath and $commentModelPath");
require_once $ticketModelPath; require_once $ticketModelPath;
require_once $commentModelPath; require_once $commentModelPath;
require_once $auditLogModelPath; require_once $auditLogModelPath;
require_once $workflowModelPath; require_once $workflowModelPath;
debug_log("Models loaded successfully"); require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
// Check authentication via session // Check authentication via session
session_start(); if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required"); throw new Exception("Authentication required");
} }
@@ -75,31 +52,33 @@ try {
$currentUser = $_SESSION['user']; $currentUser = $_SESSION['user'];
$userId = $currentUser['user_id']; $userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false; $isAdmin = $currentUser['is_admin'] ?? false;
debug_log("User authenticated: " . $currentUser['username'] . " (admin: " . ($isAdmin ? 'yes' : 'no') . ")");
// Updated controller class that handles partial updates // Updated controller class that handles partial updates
class ApiTicketController { class ApiTicketController
{
private $conn;
private $ticketModel; private $ticketModel;
private $commentModel; private $commentModel;
private $auditLog; private $auditLog;
private $workflowModel; private $workflowModel;
private $envVars;
private $userId; private $userId;
private $isAdmin; private $isAdmin;
private $currentUser;
public function __construct($conn, $envVars = [], $userId = null, $isAdmin = false) { public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = [])
{
$this->conn = $conn;
$this->ticketModel = new TicketModel($conn); $this->ticketModel = new TicketModel($conn);
$this->commentModel = new CommentModel($conn); $this->commentModel = new CommentModel($conn);
$this->auditLog = new AuditLogModel($conn); $this->auditLog = new AuditLogModel($conn);
$this->workflowModel = new WorkflowModel($conn); $this->workflowModel = new WorkflowModel($conn);
$this->envVars = $envVars;
$this->userId = $userId; $this->userId = $userId;
$this->isAdmin = $isAdmin; $this->isAdmin = $isAdmin;
$this->currentUser = $currentUser;
} }
public function update($id, $data) { public function update($id, $data)
debug_log("ApiTicketController->update called with ID: $id and data: " . json_encode($data)); {
// First, get the current ticket data to fill in missing fields // First, get the current ticket data to fill in missing fields
$currentTicket = $this->ticketModel->getTicketById($id); $currentTicket = $this->ticketModel->getTicketById($id);
if (!$currentTicket) { if (!$currentTicket) {
@@ -108,9 +87,19 @@ try {
'error' => 'Ticket not found' 'error' => 'Ticket not found'
]; ];
} }
debug_log("Current ticket data: " . json_encode($currentTicket)); // Visibility check: return 404 for tickets the user cannot access
if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) {
return [
'success' => false,
'error' => 'Ticket not found',
'http_status' => 404
];
}
// Any authenticated team member can update tickets.
// Admin-only operations (delete, bulk actions) are enforced separately.
// Merge current data with updates, keeping existing values for missing fields // Merge current data with updates, keeping existing values for missing fields
$updateData = [ $updateData = [
'ticket_id' => $id, 'ticket_id' => $id,
@@ -121,9 +110,7 @@ try {
'status' => $data['status'] ?? $currentTicket['status'], 'status' => $data['status'] ?? $currentTicket['status'],
'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority'] 'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority']
]; ];
debug_log("Merged update data: " . json_encode($updateData));
// Validate required fields // Validate required fields
if (empty($updateData['title'])) { if (empty($updateData['title'])) {
return [ return [
@@ -131,7 +118,7 @@ try {
'error' => 'Title cannot be empty' 'error' => 'Title cannot be empty'
]; ];
} }
// Validate priority range // Validate priority range
if ($updateData['priority'] < 1 || $updateData['priority'] > 5) { if ($updateData['priority'] < 1 || $updateData['priority'] > 5) {
return [ return [
@@ -139,7 +126,7 @@ try {
'error' => 'Priority must be between 1 and 5' 'error' => 'Priority must be between 1 and 5'
]; ];
} }
// Validate status transition using workflow model // Validate status transition using workflow model
if ($currentTicket['status'] !== $updateData['status']) { if ($currentTicket['status'] !== $updateData['status']) {
$allowed = $this->workflowModel->isTransitionAllowed( $allowed = $this->workflowModel->isTransitionAllowed(
@@ -155,197 +142,159 @@ try {
]; ];
} }
} }
debug_log("Validation passed, calling ticketModel->updateTicket");
// Update ticket with user tracking // Update ticket with user tracking and optional optimistic locking
$result = $this->ticketModel->updateTicket($updateData, $this->userId); $expectedUpdatedAt = $data['expected_updated_at'] ?? null;
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
debug_log("TicketModel->updateTicket returned: " . ($result ? 'true' : 'false')); // Handle conflict case
if (!$result['success']) {
$response = [
'success' => false,
'error' => $result['error'] ?? 'Failed to update ticket in database'
];
if (!empty($result['conflict'])) {
$response['conflict'] = true;
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
}
return $response;
}
if ($result) { // Handle visibility update if provided
// Log ticket update to audit log if (isset($data['visibility'])) {
if ($this->userId) { $visibilityGroups = $data['visibility_groups'] ?? null;
$this->auditLog->logTicketUpdate($this->userId, $id, $data); // Convert array to comma-separated string if needed
if (is_array($visibilityGroups)) {
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
} }
// Discord webhook disabled for updates - only send for new tickets // Validate internal visibility requires groups
// $this->sendDiscordWebhook($id, $currentTicket, $updateData, $data); if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
return [
return [ 'success' => false,
'success' => true, 'error' => 'Internal visibility requires at least one group to be specified'
'status' => $updateData['status'],
'priority' => $updateData['priority'],
'message' => 'Ticket updated successfully'
];
} else {
return [
'success' => false,
'error' => 'Failed to update ticket in database'
];
}
}
private function sendDiscordWebhook($ticketId, $oldData, $newData, $changedFields) {
if (!isset($this->envVars['DISCORD_WEBHOOK_URL']) || empty($this->envVars['DISCORD_WEBHOOK_URL'])) {
debug_log("Discord webhook URL not configured, skipping webhook");
return;
}
$webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL'];
debug_log("Sending Discord webhook to: $webhookUrl");
// Determine what fields actually changed
$changes = [];
foreach ($changedFields as $field => $newValue) {
if ($field === 'ticket_id') continue; // Skip ticket_id
$oldValue = $oldData[$field] ?? 'N/A';
if ($oldValue != $newValue) {
$changes[] = [
'name' => ucfirst($field),
'value' => "$oldValue$newValue",
'inline' => true
]; ];
} }
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
if ($visResult && $this->userId) {
$this->auditLog->log(
$this->userId,
'update',
'ticket',
(string)$id,
[
'field' => 'visibility',
'from' => $currentTicket['visibility'] ?? 'public',
'to' => $data['visibility'],
'groups' => $visibilityGroups
]
);
}
} }
if (empty($changes)) { // Log ticket update to audit log — only the changed fields (delta)
debug_log("No actual changes detected, skipping webhook"); if ($this->userId) {
return; $trackFields = ['title', 'priority', 'status', 'description', 'category', 'type'];
$delta = [];
foreach ($trackFields as $field) {
$oldVal = (string)($currentTicket[$field] ?? '');
$newVal = (string)($updateData[$field] ?? '');
if ($oldVal !== $newVal) {
$delta[$field] = ['from' => $oldVal, 'to' => $newVal];
}
}
if (!empty($delta)) {
$this->auditLog->logTicketUpdate($this->userId, $id, $delta);
}
} }
// Create ticket URL // Notify on status change (global notify list + watchers)
$ticketUrl = "http://t.lotusguild.org/ticket/$ticketId"; if ($currentTicket['status'] !== $updateData['status']) {
$changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null;
// Determine embed color based on priority NotificationHelper::sendStatusChangeNotification(
$colors = [ $id,
1 => 0xff4d4d, // Red $currentTicket['status'],
2 => 0xffa726, // Orange $updateData['status'],
3 => 0x42a5f5, // Blue $updateData['title'],
4 => 0x66bb6a, // Green $changedBy
5 => 0x9e9e9e // Gray );
]; NotificationHelper::notifyWatchers(
$color = $colors[$newData['priority']] ?? 0x3498db; $this->conn,
$id,
$embed = [ $updateData['title'],
'title' => '🔄 Ticket Updated', 'status_changed',
'description' => "**#{$ticketId}** - " . $newData['title'], ['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
'color' => $color, (int)$this->userId
'fields' => array_merge($changes, [ );
[
'name' => '🔗 View Ticket',
'value' => "[Click here to view]($ticketUrl)",
'inline' => false
]
]),
'footer' => [
'text' => 'Tinker Tickets'
],
'timestamp' => date('c')
];
$payload = [
'embeds' => [$embed]
];
debug_log("Discord payload: " . json_encode($payload));
// Send webhook
$ch = curl_init($webhookUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$webhookResult = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
debug_log("Discord webhook cURL error: $curlError");
} else {
debug_log("Discord webhook sent. HTTP Code: $httpCode, Response: $webhookResult");
} }
return [
'success' => true,
'status' => $updateData['status'],
'priority' => $updateData['priority'],
'updated_at' => date('Y-m-d H:i:s'),
'message' => 'Ticket updated successfully'
];
} }
} }
debug_log("Controller defined successfully"); // Use centralized database connection
$conn = Database::getConnection();
// Create database connection
debug_log("Creating 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);
}
debug_log("Database connection successful");
// Check request method // Check request method
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
throw new Exception("Method not allowed. Expected POST, got " . $_SERVER['REQUEST_METHOD']); throw new Exception("Method not allowed. Expected POST, got " . $_SERVER['REQUEST_METHOD']);
} }
// Get POST data // Get POST data
$input = file_get_contents('php://input'); $input = file_get_contents('php://input');
$data = json_decode($input, true); $data = json_decode($input, true);
debug_log("Received raw input: " . $input);
debug_log("Decoded data: " . json_encode($data));
if (!$data) { if (!$data) {
throw new Exception("Invalid JSON data received: " . $input); throw new Exception("Invalid JSON data received: " . $input);
} }
if (!isset($data['ticket_id'])) { if (!isset($data['ticket_id'])) {
throw new Exception("Missing ticket_id parameter"); throw new Exception("Missing ticket_id parameter");
} }
$ticketId = (int)$data['ticket_id']; $ticketId = trim((string)$data['ticket_id']);
debug_log("Processing ticket ID: $ticketId");
// Initialize controller // Initialize controller
debug_log("Initializing controller"); $controller = new ApiTicketController($conn, $userId, $isAdmin, $currentUser);
$controller = new ApiTicketController($conn, $envVars, $userId, $isAdmin);
debug_log("Controller initialized");
// Update ticket // Update ticket
debug_log("Calling controller update method");
$result = $controller->update($ticketId, $data); $result = $controller->update($ticketId, $data);
debug_log("Update completed with result: " . json_encode($result));
// Close database connection
$conn->close();
// Discard any output that might have been generated // Discard any output that might have been generated
ob_end_clean(); ob_end_clean();
// Invalidate stats cache on successful ticket update
if (!empty($result['success'])) {
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($conn))->invalidateCache();
}
// Return response // Return response
if (!empty($result['http_status'])) {
http_response_code($result['http_status']);
unset($result['http_status']);
}
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode($result); echo json_encode($result);
debug_log("Response sent successfully");
} catch (Exception $e) { } catch (Exception $e) {
debug_log("Error: " . $e->getMessage());
debug_log("Stack trace: " . $e->getTraceAsString());
// Discard any output that might have been generated // Discard any output that might have been generated
ob_end_clean(); ob_end_clean();
// Log error details but don't expose to client
error_log("Update ticket API error: " . $e->getMessage());
// Return error response // Return error response
header('Content-Type: application/json'); header('Content-Type: application/json');
http_response_code(500); http_response_code(500);
echo json_encode([ echo json_encode([
'success' => false, 'success' => false,
'error' => $e->getMessage() 'error' => 'An internal error occurred'
]); ]);
debug_log("Error response sent");
} }
?>
+244
View File
@@ -0,0 +1,244 @@
<?php
/**
* Upload Attachment API
*
* Handles file uploads for ticket attachments
*/
// Capture errors for debugging
ini_set('display_errors', 0);
error_reporting(E_ALL);
// Apply rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
// Ensure session is started
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
ResponseHelper::unauthorized();
}
// Handle GET requests to list attachments
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$ticketId = $_GET['ticket_id'] ?? '';
if (empty($ticketId)) {
ResponseHelper::error('Ticket ID is required');
}
// Validate ticket ID format (positive integer)
if (!preg_match('/^\d+$/', $ticketId)) {
ResponseHelper::error('Invalid ticket ID format');
}
try {
$conn = Database::getConnection();
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById((int)$ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
ResponseHelper::notFound('Ticket not found');
}
$attachmentModel = new AttachmentModel($conn);
$attachments = $attachmentModel->getAttachments($ticketId);
// Add formatted file size and icon to each attachment
foreach ($attachments as &$att) {
$att['file_size_formatted'] = AttachmentModel::formatFileSize($att['file_size']);
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
}
ResponseHelper::success(['attachments' => $attachments]);
} catch (Exception $e) {
ResponseHelper::serverError('Failed to load attachments');
}
}
// Only accept POST requests for uploads
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
ResponseHelper::error('Method not allowed', 405);
}
// Verify CSRF token
$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ResponseHelper::forbidden('Invalid CSRF token');
}
// Get ticket ID
$ticketId = $_POST['ticket_id'] ?? '';
if (empty($ticketId)) {
ResponseHelper::error('Ticket ID is required');
}
// Validate ticket ID format (positive integer)
if (!preg_match('/^\d+$/', $ticketId)) {
ResponseHelper::error('Invalid ticket ID format');
}
// Verify user can access the ticket before accepting upload
$conn = Database::getConnection();
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById((int)$ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
ResponseHelper::notFound('Ticket not found');
}
// Check if file was uploaded
if (!isset($_FILES['file']) || $_FILES['file']['error'] === UPLOAD_ERR_NO_FILE) {
ResponseHelper::error('No file uploaded');
}
$file = $_FILES['file'];
// Check for upload errors
if ($file['error'] !== UPLOAD_ERR_OK) {
$errorMessages = [
UPLOAD_ERR_INI_SIZE => 'File exceeds upload_max_filesize directive',
UPLOAD_ERR_FORM_SIZE => 'File exceeds MAX_FILE_SIZE directive',
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder',
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
UPLOAD_ERR_EXTENSION => 'File upload stopped by extension'
];
$message = $errorMessages[$file['error']] ?? 'Unknown upload error';
ResponseHelper::error($message);
}
// Check file size
$maxSize = $GLOBALS['config']['MAX_UPLOAD_SIZE'] ?? 10485760; // 10MB default
if ($file['size'] > $maxSize) {
ResponseHelper::error('File size exceeds maximum allowed (' . AttachmentModel::formatFileSize($maxSize) . ')');
}
// Get MIME type
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
// Validate file type
if (!AttachmentModel::isAllowedType($mimeType)) {
ResponseHelper::error('File type not allowed: ' . $mimeType);
}
// Create upload directory if it doesn't exist
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
if (!is_dir($uploadDir)) {
if (!mkdir($uploadDir, 0755, true)) {
ResponseHelper::serverError('Failed to create upload directory');
}
}
// 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)
$mimeToExt = [
'image/jpeg' => 'jpg', 'image/png' => 'png',
'image/gif' => 'gif', 'image/webp' => 'webp',
'application/pdf' => 'pdf',
'text/plain' => 'txt', 'text/csv' => 'csv',
'application/msword' => 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/vnd.ms-excel' => 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'application/zip' => 'zip',
'application/x-7z-compressed' => '7z',
'application/x-tar' => 'tar',
'application/gzip' => 'gz',
'application/json' => 'json',
'application/xml' => 'xml',
];
$safeExtension = $mimeToExt[$mimeType] ?? 'bin';
$uniqueFilename = uniqid('att_', true) . '.' . $safeExtension;
$targetPath = $ticketDir . '/' . $uniqueFilename;
// Move uploaded file
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
ResponseHelper::serverError('Failed to move uploaded file');
}
// Sanitize original filename
$originalFilename = basename($file['name']);
$originalFilename = preg_replace('/[^\w\s\-\.]/', '', $originalFilename);
if (empty($originalFilename)) {
$originalFilename = 'attachment' . ($safeExtension ? '.' . $safeExtension : '');
}
// Save to database
try {
$attachmentModel = new AttachmentModel($conn);
$attachmentId = $attachmentModel->addAttachment(
$ticketId,
$uniqueFilename,
$originalFilename,
$file['size'],
$mimeType,
$_SESSION['user']['user_id']
);
if (!$attachmentId) {
// Clean up file if database insert fails
unlink($targetPath);
ResponseHelper::serverError('Failed to save attachment record');
}
// Log the upload
$conn = Database::getConnection();
$auditLog = new AuditLogModel($conn);
$auditLog->log(
$_SESSION['user']['user_id'],
'attachment_upload',
'ticket_attachments',
(string)$attachmentId,
[
'ticket_id' => $ticketId,
'filename' => $originalFilename,
'size' => $file['size'],
'mime_type' => $mimeType
]
);
ResponseHelper::created([
'attachment_id' => $attachmentId,
'filename' => $originalFilename,
'file_size' => $file['size'],
'file_size_formatted' => AttachmentModel::formatFileSize($file['size']),
'mime_type' => $mimeType,
'icon' => AttachmentModel::getFileIcon($mimeType),
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
'uploaded_at' => date('Y-m-d H:i:s')
], 'File uploaded successfully');
} catch (Exception $e) {
// Clean up file on error
if (file_exists($targetPath)) {
unlink($targetPath);
}
ResponseHelper::serverError('Failed to process attachment');
}
+171
View File
@@ -0,0 +1,171 @@
<?php
/**
* User Avatar API
*
* Serves profile pictures fetched from lldap via LDAP.
* Caches images locally to avoid repeated LDAP queries.
*
* GET /api/user_avatar.php?user_id=123
* Returns the user's JPEG avatar (from cache or LDAP).
* Returns 404 if the user has no avatar set in lldap.
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
// Must be authenticated
if (!isset($_SESSION['user']['user_id'])) {
http_response_code(401);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
exit;
}
$cfg = $GLOBALS['config'];
// Validate user_id parameter
$userId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : 0;
if ($userId <= 0) {
http_response_code(400);
exit;
}
// Ensure LDAP is enabled and extension is loaded
if (!$cfg['LDAP_ENABLED'] || !extension_loaded('ldap')) {
http_response_code(404);
exit;
}
// Ensure avatar cache directory exists
$cacheDir = rtrim($cfg['AVATAR_CACHE_DIR'], '/');
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0755, true);
}
// 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) {
header('Content-Type: image/jpeg');
header('Cache-Control: private, max-age=' . $cacheTtl);
header('X-Avatar-Source: cache');
readfile($cacheFile);
exit;
}
// A sentinel empty file means "no avatar" — don't re-query LDAP until TTL expires
if (file_exists($noAvatarSentinel) && (time() - filemtime($noAvatarSentinel)) < $cacheTtl) {
http_response_code(404);
exit;
}
// Look up username from DB
try {
$conn = Database::getConnection();
$stmt = $conn->prepare("SELECT username FROM users WHERE user_id = ? LIMIT 1");
$stmt->bind_param('i', $userId);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
$stmt->close();
if (!$row || empty($row['username'])) {
http_response_code(404);
exit;
}
$username = $row['username'];
} catch (Exception $e) {
error_log("user_avatar: DB error for user_id=$userId: " . $e->getMessage());
http_response_code(500);
exit;
}
// Query lldap via LDAP
$ldapHost = $cfg['LDAP_HOST'];
$ldapPort = $cfg['LDAP_PORT'];
$bindDn = $cfg['LDAP_BIND_DN'];
$bindPw = $cfg['LDAP_BIND_PW'];
$userBase = $cfg['LDAP_USER_BASE'];
// Escape username for LDAP filter (RFC 4515)
$safeUsername = ldap_escape($username, '', LDAP_ESCAPE_FILTER);
$filter = "(uid=$safeUsername)";
$avatarData = null;
try {
$ldap = @ldap_connect("ldap://$ldapHost:$ldapPort");
if (!$ldap) {
throw new RuntimeException("ldap_connect failed");
}
ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_set_option($ldap, LDAP_OPT_REFERRALS, 0);
ldap_set_option($ldap, LDAP_OPT_NETWORK_TIMEOUT, 3);
ldap_set_option($ldap, LDAP_OPT_TIMELIMIT, 3);
if (!@ldap_bind($ldap, $bindDn, $bindPw)) {
throw new RuntimeException("LDAP bind failed: " . ldap_error($ldap));
}
$search = @ldap_search($ldap, $userBase, $filter, ['avatar'], 0, 1, 3);
if (!$search) {
throw new RuntimeException("LDAP search failed: " . ldap_error($ldap));
}
$entries = ldap_get_entries($ldap, $search);
if ($entries['count'] > 0 && !empty($entries[0]['avatar'][0])) {
// ldap_get_entries() returns the attribute value as raw binary.
$avatarData = $entries[0]['avatar'][0];
}
ldap_unbind($ldap);
} catch (Exception $e) {
error_log("user_avatar: LDAP error for username=$username: " . $e->getMessage());
// Fall through to 404
}
if ($avatarData === null || strlen($avatarData) < 100) {
// Write sentinel so we don't hammer LDAP for users without avatars
file_put_contents($noAvatarSentinel, '');
http_response_code(404);
exit;
}
// Validate it's actually a JPEG (magic bytes FF D8 FF)
if (substr($avatarData, 0, 3) !== "\xFF\xD8\xFF") {
error_log("user_avatar: non-JPEG data for username=$username");
file_put_contents($noAvatarSentinel, '');
http_response_code(404);
exit;
}
// Cache to disk
file_put_contents($cacheFile, $avatarData);
// Remove stale sentinel if present
if (file_exists($noAvatarSentinel)) {
unlink($noAvatarSentinel);
}
header('Content-Type: image/jpeg');
header('Cache-Control: private, max-age=' . $cacheTtl);
header('X-Avatar-Source: ldap');
echo $avatarData;
+48 -68
View File
@@ -1,109 +1,93 @@
<?php <?php
/** /**
* User Preferences API Endpoint * User Preferences API Endpoint
* Handles GET (fetch preferences) and POST (update preference) * Handles GET (fetch preferences) and POST (update preference)
*/ */
require_once dirname(__DIR__) . '/config/config.php'; require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php'; require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
session_start();
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit;
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
$userId = $_SESSION['user']['user_id'];
// 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) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
exit;
}
$prefsModel = new UserPreferencesModel($conn); $prefsModel = new UserPreferencesModel($conn);
// GET - Fetch all preferences for user // GET - Fetch all preferences for user
if ($_SERVER['REQUEST_METHOD'] === 'GET') { if ($_SERVER['REQUEST_METHOD'] === 'GET') {
try { try {
$prefs = $prefsModel->getUserPreferences($userId); $prefs = $prefsModel->getUserPreferences($userId);
echo json_encode(['success' => true, 'preferences' => $prefs]); apiRespond(['success' => true, 'preferences' => $prefs]);
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to fetch preferences']); apiRespond(['success' => false, 'error' => 'Failed to fetch preferences']);
} }
$conn->close();
exit; exit;
} }
// POST - Update a preference // POST - Update preference(s)
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
if (!isset($data['key']) || !isset($data['value'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing key or value']);
$conn->close();
exit;
}
$key = trim($data['key']);
$value = $data['value'];
// Validate preference key (whitelist) // Validate preference key (whitelist)
$validKeys = [ $validKeys = [
'rows_per_page', 'rows_per_page',
'default_status_filters', 'default_status_filters',
'table_density', 'table_density',
'timezone',
'notifications_enabled', 'notifications_enabled',
'sound_effects', 'sound_effects',
'toast_duration' 'toast_duration',
'notif_last_seen',
]; ];
// Support batch save: { preferences: { key: value, ... } }
if (isset($data['preferences']) && is_array($data['preferences'])) {
try {
foreach ($data['preferences'] as $key => $value) {
$key = trim($key);
if (!in_array($key, $validKeys)) {
continue;
}
$prefsModel->setPreference($userId, $key, (string)$value);
if ($key === 'rows_per_page') {
setcookie('ticketsPerPage', $value, ['expires' => time() + (86400 * 365), 'path' => '/', 'httponly' => true, 'secure' => true, 'samesite' => 'Lax']);
}
}
apiRespond(['success' => true]);
} catch (Exception $e) {
http_response_code(500);
apiRespond(['success' => false, 'error' => 'Failed to save preferences']);
}
exit;
}
// Single preference: { key, value }
if (!isset($data['key']) || !isset($data['value'])) {
http_response_code(400);
apiRespond(['success' => false, 'error' => 'Missing key or value']);
exit;
}
$key = trim($data['key']);
$value = $data['value'];
if (!in_array($key, $validKeys)) { if (!in_array($key, $validKeys)) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid preference key']); apiRespond(['success' => false, 'error' => 'Invalid preference key']);
$conn->close();
exit; exit;
} }
try { try {
$success = $prefsModel->setPreference($userId, $key, $value); $success = $prefsModel->setPreference($userId, $key, (string)$value);
// Also update cookie for rows_per_page for backwards compatibility // Also update cookie for rows_per_page for backwards compatibility
if ($key === 'rows_per_page') { if ($key === 'rows_per_page') {
setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/'); setcookie('ticketsPerPage', (string)$value, ['expires' => time() + (86400 * 365), 'path' => '/', 'httponly' => true, 'secure' => true, 'samesite' => 'Lax']);
} }
echo json_encode(['success' => $success]); apiRespond(['success' => $success]);
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save preference']); apiRespond(['success' => false, 'error' => 'Failed to save preference']);
} }
$conn->close();
exit; exit;
} }
@@ -113,24 +97,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
if (!isset($data['key'])) { if (!isset($data['key'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing key']); apiRespond(['success' => false, 'error' => 'Missing key']);
$conn->close();
exit; exit;
} }
try { try {
$success = $prefsModel->deletePreference($userId, $data['key']); $success = $prefsModel->deletePreference($userId, $data['key']);
echo json_encode(['success' => $success]); apiRespond(['success' => $success]);
} catch (Exception $e) { } catch (Exception $e) {
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to delete preference']); apiRespond(['success' => false, 'error' => 'Failed to delete preference']);
} }
$conn->close();
exit; exit;
} }
// Method not allowed // Method not allowed
http_response_code(405); http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']); apiRespond(['success' => false, 'error' => 'Method not allowed']);
$conn->close();
?>
+113
View File
@@ -0,0 +1,113 @@
<?php
/**
* Watch / Unwatch Ticket API
*
* GET ?ticket_id=N → returns { watching: bool, watcher_count: int }
* POST { ticket_id, action: 'watch'|'unwatch' } → toggles watcher row
*/
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
$ticketId = isset($_GET['ticket_id'])
? (int)$_GET['ticket_id']
: (isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$ticketId = (int)($data['ticket_id'] ?? 0);
$action = $data['action'] ?? '';
if ($ticketId <= 0 || !in_array($action, ['watch', 'unwatch'], true)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
exit;
}
$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;
}
if ($action === 'watch') {
$stmt = $conn->prepare(
"INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)"
);
$stmt->bind_param("ii", $ticketId, $userId);
$stmt->execute();
$stmt->close();
} else {
$stmt = $conn->prepare(
"DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
);
$stmt->bind_param("ii", $ticketId, $userId);
$stmt->execute();
$stmt->close();
}
// Return updated state
$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();
apiRespond([
'success' => true,
'watching' => $action === 'watch',
'watcher_count' => $count,
]);
}
// GET — return current watch state for this user
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
exit;
}
if ($ticketId <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ticket_id required']);
exit;
}
$watchingStmt = $conn->prepare(
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
);
$watchingStmt->bind_param("ii", $ticketId, $userId);
$watchingStmt->execute();
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
$watchingStmt->close();
// Fetch watcher list (up to 6) with display names for avatar group
$watchersStmt = $conn->prepare(
"SELECT u.user_id, COALESCE(u.display_name, u.username) AS display_name
FROM ticket_watchers tw
JOIN users u ON tw.user_id = u.user_id
WHERE tw.ticket_id = ?
ORDER BY tw.created_at ASC
LIMIT 6"
);
$watchersStmt->bind_param("i", $ticketId);
$watchersStmt->execute();
$watchersResult = $watchersStmt->get_result();
$watchers = [];
while ($row = $watchersResult->fetch_assoc()) {
$watchers[] = ['user_id' => (int)$row['user_id'], 'display_name' => $row['display_name']];
}
$watchersStmt->close();
$count = count($watchers);
echo json_encode([
'success' => true,
'watching' => $watching,
'watcher_count' => $count,
'watchers' => $watchers,
]);
+5792
View File
File diff suppressed because it is too large Load Diff
+421 -2969
View File
File diff suppressed because it is too large Load Diff
+328 -1138
View File
File diff suppressed because it is too large Load Diff
+26 -80
View File
@@ -7,8 +7,7 @@
function openAdvancedSearch() { function openAdvancedSearch() {
const modal = document.getElementById('advancedSearchModal'); const modal = document.getElementById('advancedSearchModal');
if (modal) { if (modal) {
modal.style.display = 'flex'; lt.modal.open('advancedSearchModal');
document.body.classList.add('modal-open');
loadUsersForSearch(); loadUsersForSearch();
populateCurrentFilters(); populateCurrentFilters();
loadSavedFilters(); loadSavedFilters();
@@ -17,26 +16,13 @@ function openAdvancedSearch() {
// Close advanced search modal // Close advanced search modal
function closeAdvancedSearch() { function closeAdvancedSearch() {
const modal = document.getElementById('advancedSearchModal'); lt.modal.close('advancedSearchModal');
if (modal) {
modal.style.display = 'none';
document.body.classList.remove('modal-open');
}
}
// Close modal when clicking on backdrop
function closeOnAdvancedSearchBackdropClick(event) {
const modal = document.getElementById('advancedSearchModal');
if (event.target === modal) {
closeAdvancedSearch();
}
} }
// Load users for dropdown // Load users for dropdown
async function loadUsersForSearch() { async function loadUsersForSearch() {
try { try {
const response = await fetch('/api/get_users.php'); const data = await lt.api.get('/api/get_users.php');
const data = await response.json();
if (data.success && data.users) { if (data.success && data.users) {
const createdBySelect = document.getElementById('adv-created-by'); const createdBySelect = document.getElementById('adv-created-by');
@@ -66,7 +52,7 @@ async function loadUsersForSearch() {
}); });
} }
} catch (error) { } catch (error) {
console.error('Error loading users:', error); lt.toast.error('Error loading users');
} }
} }
@@ -154,36 +140,21 @@ async function saveCurrentFilter() {
'My Filter', 'My Filter',
async (filterName) => { async (filterName) => {
if (!filterName || filterName.trim() === '') { if (!filterName || filterName.trim() === '') {
toast.warning('Filter name cannot be empty', 2000); lt.toast.warning('Filter name cannot be empty', 2000);
return; return;
} }
const filterCriteria = getCurrentFilterCriteria(); const filterCriteria = getCurrentFilterCriteria();
try { try {
const response = await fetch('/api/saved_filters.php', { await lt.api.post('/api/saved_filters.php', {
method: 'POST', filter_name: filterName.trim(),
headers: { filter_criteria: filterCriteria
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
filter_name: filterName.trim(),
filter_criteria: filterCriteria
})
}); });
lt.toast.success(`Filter "${filterName}" saved successfully!`, 3000);
const result = await response.json(); loadSavedFilters();
if (result.success) {
toast.success(`Filter "${filterName}" saved successfully!`, 3000);
loadSavedFilters();
} else {
toast.error('Failed to save filter: ' + (result.error || 'Unknown error'), 4000);
}
} catch (error) { } catch (error) {
console.error('Error saving filter:', error); lt.toast.error('Error saving filter: ' + (error.message || 'Unknown error'), 4000);
toast.error('Error saving filter', 4000);
} }
} }
); );
@@ -210,7 +181,7 @@ function getCurrentFilterCriteria() {
const statusSelect = document.getElementById('adv-status'); const statusSelect = document.getElementById('adv-status');
const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value); const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value);
if (selectedStatuses.length > 0) criteria.status = selectedStatuses.join(','); if (selectedStatuses.length > 0) criteria.status = selectedStatuses; // keep as array — pill handler uses .join(',')
const priorityMin = document.getElementById('adv-priority-min').value; const priorityMin = document.getElementById('adv-priority-min').value;
if (priorityMin) criteria.priority_min = priorityMin; if (priorityMin) criteria.priority_min = priorityMin;
@@ -230,14 +201,12 @@ function getCurrentFilterCriteria() {
// Load saved filters // Load saved filters
async function loadSavedFilters() { async function loadSavedFilters() {
try { try {
const response = await fetch('/api/saved_filters.php'); const data = await lt.api.get('/api/saved_filters.php');
const data = await response.json();
if (data.success && data.filters) { if (data.success && data.filters) {
populateSavedFiltersDropdown(data.filters); populateSavedFiltersDropdown(data.filters);
} }
} catch (error) { } catch (error) {
console.error('Error loading saved filters:', error); lt.toast.error('Error loading saved filters');
} }
} }
@@ -272,7 +241,7 @@ function loadSavedFilter() {
const criteria = JSON.parse(selectedOption.dataset.criteria); const criteria = JSON.parse(selectedOption.dataset.criteria);
applySavedFilterCriteria(criteria); applySavedFilterCriteria(criteria);
} catch (error) { } catch (error) {
console.error('Error loading filter:', error); lt.toast.error('Error loading filter');
} }
} }
@@ -287,9 +256,11 @@ function applySavedFilterCriteria(criteria) {
document.getElementById('adv-updated-from').value = criteria.updated_from || ''; document.getElementById('adv-updated-from').value = criteria.updated_from || '';
document.getElementById('adv-updated-to').value = criteria.updated_to || ''; document.getElementById('adv-updated-to').value = criteria.updated_to || '';
// Status // Status — criteria.status may be an array (new saves) or a comma-joined string (old saves)
const statusSelect = document.getElementById('adv-status'); const statusSelect = document.getElementById('adv-status');
const statuses = criteria.status ? criteria.status.split(',') : []; const statuses = criteria.status
? (Array.isArray(criteria.status) ? criteria.status : criteria.status.split(','))
: [];
Array.from(statusSelect.options).forEach(option => { Array.from(statusSelect.options).forEach(option => {
option.selected = statuses.includes(option.value); option.selected = statuses.includes(option.value);
}); });
@@ -309,9 +280,7 @@ async function deleteSavedFilter() {
const selectedOption = dropdown.options[dropdown.selectedIndex]; const selectedOption = dropdown.options[dropdown.selectedIndex];
if (!selectedOption || selectedOption.value === '') { if (!selectedOption || selectedOption.value === '') {
if (typeof toast !== 'undefined') { lt.toast.error('Please select a filter to delete');
toast.error('Please select a filter to delete');
}
return; return;
} }
@@ -324,44 +293,21 @@ async function deleteSavedFilter() {
'error', 'error',
async () => { async () => {
try { try {
const response = await fetch('/api/saved_filters.php', { await lt.api.delete('/api/saved_filters.php', { filter_id: filterId });
method: 'DELETE', lt.toast.success('Filter deleted successfully', 3000);
headers: { loadSavedFilters();
'Content-Type': 'application/json', resetAdvancedSearch();
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({ filter_id: filterId })
});
const result = await response.json();
if (result.success) {
toast.success('Filter deleted successfully', 3000);
loadSavedFilters();
resetAdvancedSearch();
} else {
toast.error('Failed to delete filter', 4000);
}
} catch (error) { } catch (error) {
console.error('Error deleting filter:', error); lt.toast.error('Error deleting filter', 4000);
toast.error('Error deleting filter', 4000);
} }
} }
); );
} }
// Keyboard shortcut (Ctrl+Shift+F) // Keyboard shortcut (Ctrl+Shift+F) — ESC is handled globally by lt.keys.initDefaults()
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.shiftKey && e.key === 'F') { if (e.ctrlKey && e.shiftKey && e.key === 'F') {
e.preventDefault(); e.preventDefault();
openAdvancedSearch(); openAdvancedSearch();
} }
// ESC to close
if (e.key === 'Escape') {
const modal = document.getElementById('advancedSearchModal');
if (modal && modal.style.display === 'flex') {
closeAdvancedSearch();
}
}
}); });
+2 -16
View File
@@ -60,26 +60,13 @@ function renderASCIIBanner(bannerId, containerSelector, speed = 5, addGlow = tru
const container = document.querySelector(containerSelector); const container = document.querySelector(containerSelector);
if (!container || !banner) { if (!container || !banner) {
console.error('ASCII Banner: Container or banner not found', { bannerId, containerSelector });
return; return;
} }
// Create pre element for ASCII art // Create pre element for ASCII art
const pre = document.createElement('pre'); const pre = document.createElement('pre');
pre.className = 'ascii-banner'; pre.className = addGlow ? 'ascii-banner ascii-banner--glow' : 'ascii-banner';
pre.style.margin = '0';
pre.style.fontFamily = 'var(--font-mono)';
pre.style.color = 'var(--terminal-green)';
if (addGlow) {
pre.style.textShadow = 'var(--glow-green)';
}
pre.style.fontSize = getBannerFontSize(bannerId); pre.style.fontSize = getBannerFontSize(bannerId);
pre.style.lineHeight = '1.2';
pre.style.whiteSpace = 'pre';
pre.style.overflow = 'visible';
pre.style.textAlign = 'center';
container.appendChild(pre); container.appendChild(pre);
@@ -179,8 +166,7 @@ function animatedWelcome(containerSelector) {
banner.addEventListener('bannerComplete', () => { banner.addEventListener('bannerComplete', () => {
const cursor = document.createElement('span'); const cursor = document.createElement('span');
cursor.textContent = '█'; cursor.textContent = '█';
cursor.style.animation = 'blink-caret 0.75s step-end infinite'; cursor.className = 'ascii-banner-cursor';
cursor.style.marginLeft = '5px';
banner.appendChild(cursor); banner.appendChild(cursor);
}); });
} }
+2950
View File
File diff suppressed because it is too large Load Diff
+1200 -732
View File
File diff suppressed because it is too large Load Diff
+104 -73
View File
@@ -1,81 +1,112 @@
/** /**
* Keyboard shortcuts for power users * Keyboard shortcuts for power users.
* App-specific shortcuts registered via lt.keys.on() from web_template/base.js.
* ESC, Ctrl+K, and ? are handled by lt.keys.initDefaults().
*/ */
// Track currently selected row for J/K navigation
let currentSelectedRowIndex = -1;
function navigateTableRow(direction) {
const rows = document.querySelectorAll('tbody tr');
if (rows.length === 0) return;
rows.forEach(row => row.classList.remove('keyboard-selected'));
if (direction === 'next') {
currentSelectedRowIndex = Math.min(currentSelectedRowIndex + 1, rows.length - 1);
} else {
currentSelectedRowIndex = Math.max(currentSelectedRowIndex - 1, 0);
}
const selectedRow = rows[currentSelectedRowIndex];
if (selectedRow) {
selectedRow.classList.add('keyboard-selected');
selectedRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('keydown', function(e) { if (!window.lt) return;
// Skip if user is typing in an input/textarea
if (e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.isContentEditable) {
// Allow ESC to exit edit mode even when in input
if (e.key === 'Escape') {
e.target.blur();
const editButton = document.getElementById('editButton');
if (editButton && editButton.classList.contains('active')) {
editButton.click();
}
}
return;
}
// Ctrl/Cmd + E: Toggle edit mode (on ticket pages) // Ctrl+E: Toggle edit mode (ticket pages)
if ((e.ctrlKey || e.metaKey) && e.key === 'e') { lt.keys.on('ctrl+e', function() {
e.preventDefault(); const editButton = document.getElementById('editButton');
const editButton = document.getElementById('editButton'); if (editButton) {
if (editButton) { editButton.click();
editButton.click(); lt.toast.info('Edit mode ' + (editButton.classList.contains('active') ? 'enabled' : 'disabled'));
toast.info('Edit mode ' + (editButton.classList.contains('active') ? 'enabled' : 'disabled'));
}
}
// Ctrl/Cmd + S: Save ticket (on ticket pages)
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
const editButton = document.getElementById('editButton');
if (editButton && editButton.classList.contains('active')) {
editButton.click();
toast.success('Saving ticket...');
}
}
// ESC: Cancel edit mode
if (e.key === 'Escape') {
const editButton = document.getElementById('editButton');
if (editButton && editButton.classList.contains('active')) {
// Reset without saving
window.location.reload();
}
}
// Ctrl/Cmd + K: Focus search (on dashboard)
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
const searchBox = document.querySelector('.search-box');
if (searchBox) {
searchBox.focus();
searchBox.select();
}
}
// ? : Show keyboard shortcuts help
if (e.key === '?' && !e.shiftKey) {
showKeyboardHelp();
} }
}); });
});
function showKeyboardHelp() { // Ctrl+S: Save ticket (ticket pages)
const helpText = ` lt.keys.on('ctrl+s', function() {
╔════════════════════════════════════════╗ const editButton = document.getElementById('editButton');
║ KEYBOARD SHORTCUTS ║ if (editButton && editButton.classList.contains('active')) {
╠════════════════════════════════════════╣ editButton.click();
║ Ctrl/Cmd + E : Toggle Edit Mode ║ lt.toast.success('Saving ticket...');
║ Ctrl/Cmd + S : Save Changes ║ }
║ Ctrl/Cmd + K : Focus Search ║ });
║ ESC : Cancel Edit/Close ║
? : Show This Help // ?: Show keyboard shortcuts help — use the static #lt-keys-help modal in the footer
╚════════════════════════════════════════╝ lt.keys.on('?', function() {
`; if (window.lt) lt.modal.open('lt-keys-help');
toast.info(helpText, 5000); });
}
// J: Next row
lt.keys.on('j', () => navigateTableRow('next'));
// K: Previous row
lt.keys.on('k', () => navigateTableRow('prev'));
// Enter: Open selected ticket
lt.keys.on('enter', function() {
const selectedRow = document.querySelector('tbody tr.keyboard-selected');
if (selectedRow) {
const ticketLink = selectedRow.querySelector('a[href*="/ticket/"]');
if (ticketLink) window.location.href = ticketLink.href;
}
});
// N: New ticket
lt.keys.on('n', function() {
const newTicketBtn = document.querySelector('a[href*="/create"]');
if (newTicketBtn) window.location.href = newTicketBtn.href;
});
// C: Focus comment box
lt.keys.on('c', function() {
const commentBox = document.getElementById('newComment');
if (commentBox) {
commentBox.focus();
commentBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
// G then D: Go to Dashboard (vim-style)
lt.keys.on('g', function() {
window._pendingG = true;
setTimeout(() => { window._pendingG = false; }, 1000);
});
lt.keys.on('d', function() {
if (window._pendingG) {
window._pendingG = false;
window.location.href = '/';
}
});
// 1-4: Quick status change on ticket page
['1', '2', '3', '4'].forEach(key => {
lt.keys.on(key, function() {
const statusSelect = document.getElementById('statusSelect');
if (statusSelect && !document.querySelector('.lt-modal-overlay[aria-hidden="false"]')) {
const statusMap = { '1': 'Open', '2': 'Pending', '3': 'In Progress', '4': 'Closed' };
const targetStatus = statusMap[key];
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
if (option && !option.disabled) {
statusSelect.value = targetStatus;
statusSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
}
});
});
});
+509 -17
View File
@@ -6,6 +6,23 @@
function parseMarkdown(markdown) { function parseMarkdown(markdown) {
if (!markdown) return ''; if (!markdown) return '';
// Footnotes — collect definitions and mark references with placeholders
// (must happen before HTML escaping so <sup> tags don't get escaped)
const footnotes = {};
const footnoteOrder = [];
const fnRefs = [];
markdown = markdown.replace(/^\[\^([^\]]+)\]:\s+(.+)$/gm, function(_, label, text) {
footnotes[label] = text;
return '';
});
markdown = markdown.replace(/\[\^([^\]]+)\]/g, function(_, label) {
if (!footnotes[label]) return '[^' + label + ']';
if (!footnoteOrder.includes(label)) footnoteOrder.push(label);
const n = footnoteOrder.indexOf(label) + 1;
fnRefs.push({ label, n });
return '%%FNREF' + (fnRefs.length - 1) + '%%';
});
let html = markdown; let html = markdown;
// Escape HTML first to prevent XSS // Escape HTML first to prevent XSS
@@ -13,11 +30,28 @@ function parseMarkdown(markdown) {
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;'); .replace(/>/g, '&gt;');
// Code blocks (```code```) // Ticket references (#123456789) - convert to clickable links
html = html.replace(/```([\s\S]*?)```/g, '<pre class="code-block"><code>$1</code></pre>'); html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
// Inline code (`code`) // Code blocks (```code```) - preserve content and don't process further
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>'); const codeBlocks = [];
html = html.replace(/```([\s\S]*?)```/g, function(match, code) {
codeBlocks.push('<pre class="code-block"><code>' + code + '</code></pre>');
return '%%CODEBLOCK' + (codeBlocks.length - 1) + '%%';
});
// Inline code (`code`) - preserve and don't process further
const inlineCodes = [];
html = html.replace(/`([^`]+)`/g, function(match, code) {
inlineCodes.push('<code class="inline-code">' + code + '</code>');
return '%%INLINECODE' + (inlineCodes.length - 1) + '%%';
});
// Tables (must be processed before other block elements)
html = parseMarkdownTables(html);
// Emoji :name: — common set
html = replaceEmoji(html);
// Bold (**text** or __text__) // Bold (**text** or __text__)
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
@@ -27,24 +61,68 @@ function parseMarkdown(markdown) {
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>'); html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
html = html.replace(/_(.+?)_/g, '<em>$1</em>'); html = html.replace(/_(.+?)_/g, '<em>$1</em>');
// Links [text](url) // Strikethrough (~~text~~) — must run before subscript (~)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'); html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
// Headers (# H1, ## H2, etc.) // Highlight (==text==)
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>'); html = html.replace(/==(.+?)==/g, '<mark>$1</mark>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Lists // Subscript H~2~O — single tilde (not preceded/followed by another tilde)
// Unordered lists (- item or * item) html = html.replace(/(?<!~)~([^~\n]+?)~(?!~)/g, '<sub>$1</sub>');
html = html.replace(/^\s*[-*]\s+(.+)$/gm, '<li>$1</li>');
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
// Ordered lists (1. item) // Superscript X^2^ — caret pair
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '<li>$1</li>'); html = html.replace(/\^([^\^\n]+?)\^/g, '<sup>$1</sup>');
// Images ![alt](url) - must come before link handler
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(match, alt, url) {
if (/^https?:/i.test(url)) {
return '<img src="' + url + '" alt="' + alt + '" class="md-image" loading="lazy">';
}
return match;
});
// Links [text](url) - only allow safe protocols
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) {
// Only allow http, https, mailto protocols
if (/^(https?:|mailto:|\/)/i.test(url)) {
return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + text + '</a>';
}
// Block potentially dangerous protocols (javascript:, data:, etc.)
return text;
});
// Auto-link bare URLs (http, https)
html = html.replace(/(?<!["\'>])(\bhttps?:\/\/[^\s<>\[\]()]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
// Headings with optional {#id} anchor — ### My Heading {#my-id}
html = html.replace(/^(#{1,6})\s+(.+?)\s*(?:\{#([a-z0-9_-]+)\})?$/gm, function(match, hashes, text, id) {
const level = hashes.length;
const idAttr = id ? ' id="' + id + '"' : '';
return '<h' + level + idAttr + '>' + text + '</h' + level + '>';
});
// Lists — tag each item type with a placeholder, then wrap consecutive runs
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '%%OLI%%$1');
html = html.replace(/^\s*[-*+]\s+\[x\]\s+(.+)$/gim, '%%TDI%%$1');
html = html.replace(/^\s*[-*+]\s+\[ \]\s+(.+)$/gm, '%%TTI%%$1');
html = html.replace(/^\s*[-*+]\s+(.+)$/gm, '%%ULI%%$1');
// Wrap consecutive ordered items in <ol>
html = html.replace(/(%%OLI%%.+(\n%%OLI%%.+)*)/g, function(block) {
return '<ol>' + block.replace(/%%OLI%%(.+)/g, '<li>$1</li>') + '</ol>';
});
// Wrap consecutive unordered/task items in <ul>
html = html.replace(/((?:%%(?:ULI|TDI|TTI)%%).+(?:\n(?:%%(?:ULI|TDI|TTI)%%).+)*)/g, function(block) {
return '<ul>' + block
.replace(/%%ULI%%(.+)/g, '<li>$1</li>')
.replace(/%%TDI%%(.+)/g, '<li class="task-item task-done"><span class="task-cb">&#x2611;</span> $1</li>')
.replace(/%%TTI%%(.+)/g, '<li class="task-item task-todo"><span class="task-cb">&#x2610;</span> $1</li>')
+ '</ul>';
});
// Blockquotes (> text) // Blockquotes (> text)
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>'); html = html.replace(/^&gt;\s+(.+)$/gm, '<blockquote>$1</blockquote>');
// Horizontal rules (--- or ***) // Horizontal rules (--- or ***)
html = html.replace(/^(?:---|___|\*\*\*)$/gm, '<hr>'); html = html.replace(/^(?:---|___|\*\*\*)$/gm, '<hr>');
@@ -53,11 +131,168 @@ function parseMarkdown(markdown) {
html = html.replace(/ \n/g, '<br>'); html = html.replace(/ \n/g, '<br>');
html = html.replace(/\n\n/g, '</p><p>'); html = html.replace(/\n\n/g, '</p><p>');
// Restore code blocks and inline code
codeBlocks.forEach((block, i) => {
html = html.replace('%%CODEBLOCK' + i + '%%', block);
});
inlineCodes.forEach((code, i) => {
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>');
});
// Wrap in paragraph if not already wrapped // Wrap in paragraph if not already wrapped
if (!html.startsWith('<')) { if (!html.startsWith('<')) {
html = '<p>' + html + '</p>'; html = '<p>' + html + '</p>';
} }
// 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">' +
parseMarkdown(footnotes[label]).replace(/<\/?p>/g, '') +
' <a href="#fnref-' + label + '" class="fn-back">&#x21A9;</a></li>';
});
html += '</ol>';
}
return html;
}
/**
* Replace :emoji: shortcodes with Unicode characters
*/
const _emojiMap = {
// Faces & emotions
smile: '😊', grin: '😁', joy: '😂', rofl: '🤣', smiley: '😃', sweat_smile: '😅',
wink: '😉', blush: '😊', heart_eyes: '😍', kissing: '😗', thinking: '🤔',
raised_eyebrow: '🤨', neutral_face: '😐', expressionless: '😑', unamused: '😒',
roll_eyes: '🙄', pensive: '😔', confused: '😕', worried: '😟', cry: '😢',
sob: '😭', scream: '😱', angry: '😠', rage: '😡', skull: '💀', sunglasses: '😎',
nerd: '🤓', monocle: '🧐', clown: '🤡', ghost: '👻', robot: '🤖', alien: '👽',
// Hands & people
thumbsup: '👍', '+1': '👍', thumbsdown: '👎', '-1': '👎', clap: '👏',
wave: '👋', raised_hands: '🙌', pray: '🙏', point_up: '☝️', point_right: '👉',
point_left: '👈', point_down: '👇', fist: '✊', punch: '👊', v: '✌️', ok_hand: '👌',
muscle: '💪', eyes: '👀', eye: '👁️', ear: '👂', brain: '🧠', man: '👨', woman: '👩',
// Hearts & symbols
heart: '❤️', orange_heart: '🧡', yellow_heart: '💛', green_heart: '💚',
blue_heart: '💙', purple_heart: '💜', black_heart: '🖤', broken_heart: '💔',
star: '⭐', star2: '🌟', sparkles: '✨', fire: '🔥', boom: '💥', zap: '⚡',
check: '✅', white_check_mark: '✅', x: '❌', heavy_check_mark: '✔️',
warning: '⚠️', no_entry: '⛔', stop_sign: '🛑', prohibited: '🚫',
question: '❓', exclamation: '❗', grey_question: '❔', grey_exclamation: '❕',
100: '💯', tada: '🎉', confetti_ball: '🎊', trophy: '🏆', medal: '🥇',
// Tech & work
bug: '🐛', rocket: '🚀', computer: '💻', keyboard: '⌨️', mouse: '🖱️',
printer: '🖨️', phone: '📱', email: '📧', inbox_tray: '📥', outbox_tray: '📤',
memo: '📝', pencil: '✏️', pen: '🖊️', paperclip: '📎', link: '🔗',
hammer: '🔨', wrench: '🔧', gear: '⚙️', lock: '🔒', unlock: '🔓',
key: '🔑', mag: '🔍', bar_chart: '📊', chart_increasing: '📈', chart_decreasing: '📉',
clipboard: '📋', calendar: '📅', clock: '🕐', hourglass: '⏳', bell: '🔔',
mute: '🔇', loud_sound: '🔊', bulb: '💡', battery: '🔋', electric_plug: '🔌',
recycle: '♻️', package: '📦', label: '🏷️', bookmark: '🔖', flag: '🚩',
// Nature & misc
sun: '☀️', moon: '🌙', cloud: '☁️', snowflake: '❄️', umbrella: '☂️',
dog: '🐶', cat: '🐱', pizza: '🍕', coffee: '☕', beer: '🍺',
white_flag: '🏳️', checkered_flag: '🏁', construction: '🚧', sos: '🆘',
info: '️', new: '🆕', free: '🆓', cool: '🆒', up: '🆙', soon: '🔜',
};
function replaceEmoji(text) {
return text.replace(/:([a-z0-9_+\-]+):/g, function(match, name) {
return _emojiMap[name] || match;
});
}
/**
* Parse markdown tables
* Supports: | Header | Header |
* |--------|--------|
* | Cell | Cell |
*/
function parseMarkdownTables(html) {
const lines = html.split('\n');
const result = [];
let inTable = false;
let tableRows = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Check if line is a table row (starts and ends with |, or has | in the middle)
if (line.match(/^\|.*\|$/) || line.match(/^[^|]+\|[^|]+/)) {
// Check if next line is separator (|---|---|)
const nextLine = lines[i + 1] ? lines[i + 1].trim() : '';
const isSeparator = line.match(/^\|?[\s-:|]+\|[\s-:|]+\|?$/);
if (!inTable && !isSeparator) {
// Start of table - check if this is a header row
if (nextLine.match(/^\|?[\s-:|]+\|[\s-:|]+\|?$/)) {
inTable = true;
tableRows.push({ type: 'header', content: line });
continue;
}
}
if (inTable) {
if (isSeparator) {
// Skip separator line
continue;
}
tableRows.push({ type: 'body', content: line });
continue;
}
}
// Not a table row - flush any accumulated table
if (inTable && tableRows.length > 0) {
result.push(buildTable(tableRows));
tableRows = [];
inTable = false;
}
result.push(lines[i]);
}
// Flush remaining table
if (tableRows.length > 0) {
result.push(buildTable(tableRows));
}
return result.join('\n');
}
/**
* Build HTML table from parsed rows
*/
function buildTable(rows) {
if (rows.length === 0) return '';
let html = '<table class="markdown-table">';
rows.forEach((row, index) => {
const cells = row.content.split('|').filter(cell => cell.trim() !== '');
const tag = row.type === 'header' ? 'th' : 'td';
const wrapper = row.type === 'header' ? 'thead' : (index === 1 ? 'tbody' : '');
if (wrapper === 'thead') html += '<thead>';
if (wrapper === 'tbody') html += '<tbody>';
html += '<tr>';
cells.forEach(cell => {
html += `<${tag}>${cell.trim()}</${tag}>`;
});
html += '</tr>';
if (row.type === 'header') html += '</thead>';
});
html += '</tbody></table>';
return html; return html;
} }
@@ -75,3 +310,260 @@ document.addEventListener('DOMContentLoaded', renderMarkdownElements);
// Expose for manual use // Expose for manual use
window.parseMarkdown = parseMarkdown; window.parseMarkdown = parseMarkdown;
window.renderMarkdownElements = renderMarkdownElements; window.renderMarkdownElements = renderMarkdownElements;
// ========================================
// Rich Text Editor Toolbar Functions
// ========================================
/**
* Insert markdown formatting around selection
*/
function insertMarkdownFormat(textareaId, prefix, suffix) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selectedText = text.substring(start, end);
// Insert formatting
const newText = text.substring(0, start) + prefix + selectedText + suffix + text.substring(end);
textarea.value = newText;
// Set cursor position
if (selectedText) {
textarea.setSelectionRange(start + prefix.length, end + prefix.length);
} else {
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
}
textarea.focus();
// Trigger input event to update preview if enabled
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Insert markdown at cursor position
*/
function insertMarkdownText(textareaId, text) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const value = textarea.value;
textarea.value = value.substring(0, start) + text + value.substring(start);
textarea.setSelectionRange(start + text.length, start + text.length);
textarea.focus();
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Toolbar button handlers
*/
function toolbarBold(textareaId) {
insertMarkdownFormat(textareaId, '**', '**');
}
function toolbarItalic(textareaId) {
insertMarkdownFormat(textareaId, '_', '_');
}
function toolbarCode(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
// Use code block for multi-line, inline code for single line
if (selectedText.includes('\n')) {
insertMarkdownFormat(textareaId, '```\n', '\n```');
} else {
insertMarkdownFormat(textareaId, '`', '`');
}
}
function toolbarLink(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
if (selectedText) {
// Wrap selected text as link text
insertMarkdownFormat(textareaId, '[', '](url)');
} else {
insertMarkdownText(textareaId, '[link text](url)');
}
}
function toolbarList(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const text = textarea.value;
// Find start of current line
let lineStart = start;
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
lineStart--;
}
// Insert list marker at beginning of line
textarea.value = text.substring(0, lineStart) + '- ' + text.substring(lineStart);
textarea.setSelectionRange(start + 2, start + 2);
textarea.focus();
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
function toolbarHeading(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const text = textarea.value;
// Find start of current line
let lineStart = start;
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
lineStart--;
}
// Insert heading marker at beginning of line
textarea.value = text.substring(0, lineStart) + '## ' + text.substring(lineStart);
textarea.setSelectionRange(start + 3, start + 3);
textarea.focus();
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
function toolbarQuote(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const text = textarea.value;
// Find start of current line
let lineStart = start;
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
lineStart--;
}
// Insert quote marker at beginning of line
textarea.value = text.substring(0, lineStart) + '> ' + text.substring(lineStart);
textarea.setSelectionRange(start + 2, start + 2);
textarea.focus();
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Create and insert toolbar HTML for a textarea
*/
function createEditorToolbar(textareaId, containerId) {
const container = document.getElementById(containerId);
if (!container) return;
const toolbar = document.createElement('div');
toolbar.className = 'editor-toolbar';
toolbar.innerHTML = `
<button type="button" data-toolbar-action="bold" data-textarea="${textareaId}" title="Bold (Ctrl+B)"><b>B</b></button>
<button type="button" data-toolbar-action="italic" data-textarea="${textareaId}" title="Italic (Ctrl+I)"><i>I</i></button>
<button type="button" data-toolbar-action="code" data-textarea="${textareaId}" title="Code">&lt;/&gt;</button>
<span class="toolbar-separator"></span>
<button type="button" data-toolbar-action="heading" data-textarea="${textareaId}" title="Heading">H</button>
<button type="button" data-toolbar-action="list" data-textarea="${textareaId}" title="List">≡</button>
<button type="button" data-toolbar-action="quote" data-textarea="${textareaId}" title="Quote">"</button>
<span class="toolbar-separator"></span>
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">[ @ ]</button>
`;
// Add event delegation for toolbar buttons
toolbar.addEventListener('click', function(e) {
const btn = e.target.closest('[data-toolbar-action]');
if (!btn) return;
const action = btn.dataset.toolbarAction;
const targetId = btn.dataset.textarea;
switch (action) {
case 'bold': toolbarBold(targetId); break;
case 'italic': toolbarItalic(targetId); break;
case 'code': toolbarCode(targetId); break;
case 'heading': toolbarHeading(targetId); break;
case 'list': toolbarList(targetId); break;
case 'quote': toolbarQuote(targetId); break;
case 'link': toolbarLink(targetId); break;
}
});
container.insertBefore(toolbar, container.firstChild);
}
// Expose toolbar functions globally
window.toolbarBold = toolbarBold;
window.toolbarItalic = toolbarItalic;
window.toolbarCode = toolbarCode;
window.toolbarLink = toolbarLink;
window.toolbarList = toolbarList;
window.toolbarHeading = toolbarHeading;
window.toolbarQuote = toolbarQuote;
window.createEditorToolbar = createEditorToolbar;
window.insertMarkdownFormat = insertMarkdownFormat;
window.insertMarkdownText = insertMarkdownText;
// ========================================
// Auto-link URLs in plain text (non-markdown)
// ========================================
/**
* Convert plain text URLs to clickable links
* Used for non-markdown comments
*/
function autoLinkUrls(text) {
if (!text) return '';
// Match URLs that aren't already in an href attribute
return text.replace(/(?<!["\'>])(https?:\/\/[^\s<>\[\]()]+)/g,
'<a href="$1" target="_blank" rel="noopener noreferrer" class="auto-link">$1</a>');
}
/**
* Process all non-markdown comment elements to auto-link URLs
*/
function processPlainTextComments() {
document.querySelectorAll('.comment-text:not([data-markdown])').forEach(element => {
// Only process if not already processed
if (element.dataset.linksProcessed) return;
element.innerHTML = autoLinkUrls(element.innerHTML);
element.dataset.linksProcessed = 'true';
});
}
/**
* Render all [data-markdown] comment elements that haven't been rendered yet
*/
function renderMarkdownComments() {
document.querySelectorAll('.comment-text[data-markdown]:not([data-rendered])').forEach(el => {
el.classList.add('lt-markdown');
el.innerHTML = parseMarkdown(el.textContent);
el.dataset.rendered = '1';
});
}
// Run on page load
document.addEventListener('DOMContentLoaded', function() {
renderMarkdownComments();
processPlainTextComments();
});
window.renderMarkdownComments = renderMarkdownComments;
// Expose for manual use
window.autoLinkUrls = autoLinkUrls;
window.processPlainTextComments = processPlainTextComments;
+32 -52
View File
@@ -8,14 +8,13 @@ let userPreferences = {};
// Load preferences on page load // Load preferences on page load
async function loadUserPreferences() { async function loadUserPreferences() {
try { try {
const response = await fetch('/api/user_preferences.php'); const data = await lt.api.get('/api/user_preferences.php');
const data = await response.json();
if (data.success) { if (data.success) {
userPreferences = data.preferences; userPreferences = data.preferences;
applyPreferences(); applyPreferences();
} }
} catch (error) { } catch (error) {
console.error('Error loading preferences:', error); lt.toast.error('Error loading preferences');
} }
} }
@@ -45,6 +44,13 @@ function applyPreferences() {
document.body.classList.add(`table-${density}`); document.body.classList.add(`table-${density}`);
} }
// Timezone - use server default if not set
const timezone = userPreferences.timezone || window.APP_TIMEZONE || 'America/New_York';
const timezoneSelect = document.getElementById('userTimezone');
if (timezoneSelect) {
timezoneSelect.value = timezone;
}
// Notifications // Notifications
const notificationsCheckbox = document.getElementById('notificationsEnabled'); const notificationsCheckbox = document.getElementById('notificationsEnabled');
if (notificationsCheckbox) { if (notificationsCheckbox) {
@@ -66,46 +72,31 @@ function applyPreferences() {
// Save preferences // Save preferences
async function saveSettings() { async function saveSettings() {
const rowsPerPage = document.getElementById('rowsPerPage');
const tableDensity = document.getElementById('tableDensity');
const userTimezone = document.getElementById('userTimezone');
const notificationsEnabled = document.getElementById('notificationsEnabled');
const soundEffects = document.getElementById('soundEffects');
const toastDuration = document.getElementById('toastDuration');
const prefs = { const prefs = {
rows_per_page: document.getElementById('rowsPerPage').value, rows_per_page: rowsPerPage ? rowsPerPage.value : '15',
default_status_filters: Array.from(document.querySelectorAll('[name="defaultFilters"]:checked')) default_status_filters: Array.from(document.querySelectorAll('[name="defaultFilters"]:checked'))
.map(cb => cb.value).join(','), .map(cb => cb.value).join(',') || 'Open,Pending,In Progress',
table_density: document.getElementById('tableDensity').value, table_density: tableDensity ? tableDensity.value : 'normal',
notifications_enabled: document.getElementById('notificationsEnabled').checked ? '1' : '0', timezone: userTimezone ? userTimezone.value : (window.APP_TIMEZONE || 'America/New_York'),
sound_effects: document.getElementById('soundEffects').checked ? '1' : '0', notifications_enabled: notificationsEnabled ? (notificationsEnabled.checked ? '1' : '0') : '1',
toast_duration: document.getElementById('toastDuration').value sound_effects: soundEffects ? (soundEffects.checked ? '1' : '0') : '1',
toast_duration: toastDuration ? toastDuration.value : '3000'
}; };
try { try {
// Save each preference await lt.api.post('/api/user_preferences.php', { preferences: prefs });
for (const [key, value] of Object.entries(prefs)) { lt.toast.success('Preferences saved successfully!');
const response = await fetch('/api/user_preferences.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({ key, value })
});
const result = await response.json();
if (!result.success) {
throw new Error(`Failed to save ${key}`);
}
}
if (typeof toast !== 'undefined') {
toast.success('Preferences saved successfully!');
}
closeSettingsModal(); closeSettingsModal();
// Reload page to apply new preferences
setTimeout(() => window.location.reload(), 1000); setTimeout(() => window.location.reload(), 1000);
} catch (error) { } catch (error) {
if (typeof toast !== 'undefined') { lt.toast.error('Error saving preferences');
toast.error('Error saving preferences');
}
console.error('Error saving preferences:', error);
} }
} }
@@ -113,24 +104,18 @@ async function saveSettings() {
function openSettingsModal() { function openSettingsModal() {
const modal = document.getElementById('settingsModal'); const modal = document.getElementById('settingsModal');
if (modal) { if (modal) {
modal.style.display = 'flex'; lt.modal.open('settingsModal');
document.body.classList.add('modal-open');
loadUserPreferences(); loadUserPreferences();
} }
} }
function closeSettingsModal() { function closeSettingsModal() {
const modal = document.getElementById('settingsModal'); lt.modal.close('settingsModal');
if (modal) {
modal.style.display = 'none';
document.body.classList.remove('modal-open');
}
} }
// Close modal when clicking on backdrop (outside the settings content) // Close modal when clicking on backdrop (outside the settings content)
function closeOnBackdropClick(event) { function closeOnBackdropClick(event) {
const modal = document.getElementById('settingsModal'); const modal = document.getElementById('settingsModal');
// Only close if clicking directly on the modal backdrop, not on content
if (event.target === modal) { if (event.target === modal) {
closeSettingsModal(); closeSettingsModal();
} }
@@ -142,15 +127,10 @@ document.addEventListener('keydown', (e) => {
e.preventDefault(); e.preventDefault();
openSettingsModal(); openSettingsModal();
} }
// ESC is handled globally by lt.keys.initDefaults()
// ESC to close modal
if (e.key === 'Escape') {
const modal = document.getElementById('settingsModal');
if (modal && modal.style.display === 'block') {
closeSettingsModal();
}
}
}); });
// Initialize on page load // Initialize on page load
document.addEventListener('DOMContentLoaded', loadUserPreferences); document.addEventListener('DOMContentLoaded', function() {
if (window.lt) loadUserPreferences();
});
+1380 -256
View File
File diff suppressed because it is too large Load Diff
+14 -75
View File
@@ -1,83 +1,22 @@
/** /**
* Terminal-style toast notification system with queuing * Deprecated: use lt.toast.* directly (from web_template/base.js).
* This shim maintains backwards compatibility while callers are migrated.
*/ */
// Toast queue management // showToast() shim — used by inline view scripts
let toastQueue = []; function showToast(message, type = 'info', duration = 3500) {
let currentToast = null; switch (type) {
case 'success': lt.toast.success(message, duration); break;
function showToast(message, type = 'info', duration = 3000) { case 'error': lt.toast.error(message, duration); break;
// Queue if a toast is already showing case 'warning': lt.toast.warning(message, duration); break;
if (currentToast) { default: lt.toast.info(message, duration); break;
toastQueue.push({ message, type, duration });
return;
} }
displayToast(message, type, duration);
} }
function displayToast(message, type, duration) { // window.toast.* shim — used by JS files
// Create toast element
const toast = document.createElement('div');
toast.className = `terminal-toast toast-${type}`;
currentToast = toast;
// Icon based on type
const icons = {
success: '✓',
error: '✗',
info: '',
warning: '⚠'
};
toast.innerHTML = `
<span class="toast-icon">[${icons[type] || ''}]</span>
<span class="toast-message">${message}</span>
<span class="toast-close" style="margin-left: auto; cursor: pointer; opacity: 0.7; padding-left: 1rem;">[×]</span>
`;
// Add to document
document.body.appendChild(toast);
// Trigger animation
setTimeout(() => toast.classList.add('show'), 10);
// Manual dismiss handler
const closeBtn = toast.querySelector('.toast-close');
closeBtn.addEventListener('click', () => dismissToast(toast));
// Auto-remove after duration
const timeoutId = setTimeout(() => {
dismissToast(toast);
}, duration);
// Store timeout ID for manual dismiss
toast.timeoutId = timeoutId;
}
function dismissToast(toast) {
// Clear auto-dismiss timeout
if (toast.timeoutId) {
clearTimeout(toast.timeoutId);
}
toast.classList.remove('show');
setTimeout(() => {
toast.remove();
currentToast = null;
// Show next toast in queue
if (toastQueue.length > 0) {
const next = toastQueue.shift();
displayToast(next.message, next.type, next.duration);
}
}, 300);
}
// Convenience functions
window.toast = { window.toast = {
success: (msg, duration) => showToast(msg, 'success', duration), success: (msg, dur) => lt.toast.success(msg, dur),
error: (msg, duration) => showToast(msg, 'error', duration), error: (msg, dur) => lt.toast.error(msg, dur),
info: (msg, duration) => showToast(msg, 'info', duration), warning: (msg, dur) => lt.toast.warning(msg, dur),
warning: (msg, duration) => showToast(msg, 'warning', duration) info: (msg, dur) => lt.toast.info(msg, dur),
}; };
+55
View File
@@ -0,0 +1,55 @@
// XSS prevention helper — delegates to lt.escHtml() from web_template/base.js
function escapeHtml(text) {
return lt.escHtml(text);
}
// Get ticket ID from URL (handles both /ticket/123 and ?id=123 formats)
function getTicketIdFromUrl() {
const pathMatch = window.location.pathname.match(/\/ticket\/(\d+)/);
if (pathMatch) return pathMatch[1];
const params = new URLSearchParams(window.location.search);
return params.get('id');
}
/**
* Show a terminal-style confirmation modal using the lt.modal system.
* @param {string} title - Modal title
* @param {string} message - Confirmation message
* @param {string} type - 'warning' | 'error' | 'info'
* @param {Function} onConfirm - Called when user confirms
* @param {Function|null} onCancel - Called when user cancels
*/
function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
const modalId = 'confirmModal' + Date.now();
const colors = { warning: 'var(--terminal-amber)', error: 'var(--status-closed)', info: 'var(--terminal-cyan)' };
const icons = { warning: '[ ! ]', error: '[ X ]', info: '[ i ]' };
const color = colors[type] || colors.warning;
const icon = icons[type] || icons.warning;
const safeTitle = lt.escHtml(title);
const safeMessage = lt.escHtml(message).replace(/\n/g, '<br>');
document.body.insertAdjacentHTML('beforeend', `
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header" style="color:${color};">
<span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body lt-text-center">
<p>${safeMessage}</p>
</div>
<div class="lt-modal-footer">
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button>
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">CANCEL</button>
</div>
</div>
</div>
`);
const modal = document.getElementById(modalId);
lt.modal.open(modalId);
const cleanup = (cb) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (cb) cb(); };
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => cleanup(onConfirm));
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
}
+131 -4
View File
@@ -1,14 +1,20 @@
<?php <?php
// Load environment variables // Load environment variables
$envFile = __DIR__ . '/../.env'; $envFile = __DIR__ . '/../.env';
if (!file_exists($envFile)) {
die('Configuration error: .env file not found. Copy .env.example to .env and configure your database settings.');
}
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED); $envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
// Strip quotes from values if present (parse_ini_file may include them) // Strip quotes from values if present (parse_ini_file may include them)
if ($envVars) { if ($envVars) {
foreach ($envVars as $key => $value) { foreach ($envVars as $key => $value) {
if (is_string($value)) { if (is_string($value)) {
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') || if (
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) { (substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
) {
$envVars[$key] = substr($value, 1, -1); $envVars[$key] = substr($value, 1, -1);
} }
} }
@@ -17,12 +23,133 @@ if ($envVars) {
// Global configuration // Global configuration
$GLOBALS['config'] = [ $GLOBALS['config'] = [
// Application identity
'APP_NAME' => $envVars['APP_NAME'] ?? 'TINKER TICKETS',
'APP_SUBTITLE' => $envVars['APP_SUBTITLE'] ?? 'LotusGuild Infrastructure',
'APP_VERSION' => $envVars['APP_VERSION'] ?? '1.2',
// Asset cache-busting version — auto-computed from key asset mtimes so
// browsers always pick up changes on deploy. Override via ASSET_VERSION in .env.
'ASSET_VERSION' => (function () use ($envVars) {
if (!empty($envVars['ASSET_VERSION'])) {
return $envVars['ASSET_VERSION'];
}
$files = [
__DIR__ . '/../assets/css/base.css',
__DIR__ . '/../assets/css/dashboard.css',
__DIR__ . '/../assets/css/ticket.css',
__DIR__ . '/../assets/js/base.js',
__DIR__ . '/../assets/js/dashboard.js',
__DIR__ . '/../assets/js/ticket.js',
];
$mtime = 0;
foreach ($files as $f) {
if (file_exists($f)) {
$mtime = max($mtime, filemtime($f));
}
}
return $mtime ?: '20260329';
})(),
// Canonical ticket statuses — single source of truth used by views and JS
'TICKET_STATUSES' => ['Open', 'Pending', 'In Progress', 'Closed'],
// Database settings
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost', 'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
'DB_USER' => $envVars['DB_USER'] ?? 'root', 'DB_USER' => $envVars['DB_USER'] ?? 'root',
'DB_PASS' => $envVars['DB_PASS'] ?? '', 'DB_PASS' => $envVars['DB_PASS'] ?? '',
'DB_NAME' => $envVars['DB_NAME'] ?? 'tinkertickets', 'DB_NAME' => $envVars['DB_NAME'] ?? 'tinkertickets',
// URL settings
'BASE_URL' => '', // Empty since we're serving from document root 'BASE_URL' => '', // Empty since we're serving from document root
'ASSETS_URL' => '/assets', // Assets URL 'ASSETS_URL' => '/assets', // Assets URL
'API_URL' => '/api' // API URL 'API_URL' => '/api', // API URL
// Matrix webhook (hookshot generic webhook URL)
'MATRIX_WEBHOOK_URL' => $envVars['MATRIX_WEBHOOK_URL'] ?? null,
// Comma-separated Matrix user IDs to @mention on new tickets / status changes (e.g. @jared:matrix.lotusguild.org)
'MATRIX_NOTIFY_USERS' => $envVars['MATRIX_NOTIFY_USERS'] ?? '',
// Matrix homeserver domain (e.g. matrix.lotusguild.org) — used to construct Matrix user IDs
'MATRIX_DOMAIN' => $envVars['MATRIX_DOMAIN'] ?? null,
// Internal Synapse client-API base URL (e.g. http://10.10.10.29:8008) — used to verify user existence via Admin API
'SYNAPSE_ADMIN_URL' => $envVars['SYNAPSE_ADMIN_URL'] ?? null,
// Synapse admin access token (generate with: register_new_matrix_user or admin API)
'SYNAPSE_ADMIN_TOKEN' => $envVars['SYNAPSE_ADMIN_TOKEN'] ?? null,
// Set to '1' or 'true' to send a notification when any comment is posted
'MATRIX_NOTIFY_COMMENTS' => filter_var($envVars['MATRIX_NOTIFY_COMMENTS'] ?? false, FILTER_VALIDATE_BOOLEAN),
// Set to '1' or 'true' to send a notification when a ticket is assigned
'MATRIX_NOTIFY_ASSIGNMENTS' => filter_var($envVars['MATRIX_NOTIFY_ASSIGNMENTS'] ?? false, FILTER_VALIDATE_BOOLEAN),
// Domain settings for external integrations (webhooks, links, etc.)
// Set APP_DOMAIN in .env to override
'APP_DOMAIN' => $envVars['APP_DOMAIN'] ?? null,
// Allowed hosts for HTTP_HOST validation (comma-separated in .env)
'ALLOWED_HOSTS' => array_filter(array_map(
'trim',
explode(',', $envVars['ALLOWED_HOSTS'] ?? 'localhost,127.0.0.1')
)),
// Session settings
'SESSION_TIMEOUT' => 18000, // 5 hours in seconds
'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes
// CSRF settings
'CSRF_LIFETIME' => 3600, // 1 hour in seconds
// Pagination settings
'PAGINATION_DEFAULT' => 15, // Default items per page
'PAGINATION_MAX' => 100, // Maximum items per page
// File upload settings
'MAX_UPLOAD_SIZE' => 10485760, // 10MB in bytes
'ALLOWED_FILE_TYPES' => [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'text/csv',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/zip',
'application/x-7z-compressed',
'application/x-tar',
'application/gzip'
],
'UPLOAD_DIR' => __DIR__ . '/../uploads',
// Rate limiting
'RATE_LIMIT_DEFAULT' => 100, // Requests per minute for general
'RATE_LIMIT_API' => 60, // Requests per minute for API
// Audit log settings
'AUDIT_LOG_RETENTION_DAYS' => 90,
// Timezone settings
// Default: America/New_York (EST/EDT)
// Common options: America/Chicago (CST), America/Denver (MST), America/Los_Angeles (PST), UTC
'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York',
'TIMEZONE_OFFSET' => null, // Will be calculated below
// LDAP / lldap settings (for user avatar lookups)
'LDAP_HOST' => $envVars['LDAP_HOST'] ?? '10.10.10.39',
'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 3890),
'LDAP_BIND_DN' => $envVars['LDAP_BIND_DN'] ?? 'uid=tinker-tickets,ou=people,dc=example,dc=com',
'LDAP_BIND_PW' => $envVars['LDAP_BIND_PW'] ?? '',
'LDAP_BASE_DN' => $envVars['LDAP_BASE_DN'] ?? 'dc=example,dc=com',
'LDAP_USER_BASE' => $envVars['LDAP_USER_BASE'] ?? 'ou=people,dc=example,dc=com',
'LDAP_ENABLED' => filter_var($envVars['LDAP_ENABLED'] ?? 'true', FILTER_VALIDATE_BOOLEAN),
'AVATAR_CACHE_DIR' => __DIR__ . '/../uploads/avatars',
'AVATAR_CACHE_TTL' => (int)($envVars['AVATAR_CACHE_TTL'] ?? 3600), // seconds
]; ];
?>
// Set PHP default timezone
date_default_timezone_set($GLOBALS['config']['TIMEZONE']);
// Calculate UTC offset for JavaScript (in minutes, negative for west of UTC)
$now = new DateTime('now', new DateTimeZone($GLOBALS['config']['TIMEZONE']));
$GLOBALS['config']['TIMEZONE_OFFSET'] = $now->getOffset() / 60; // Convert seconds to minutes
$GLOBALS['config']['TIMEZONE_ABBREV'] = $now->format('T'); // e.g., "EST", "EDT"
+16 -11
View File
@@ -1,23 +1,28 @@
<?php <?php
require_once 'models/CommentModel.php'; require_once 'models/CommentModel.php';
class CommentController { class CommentController
{
private $commentModel; private $commentModel;
public function __construct($conn) { public function __construct($conn)
{
$this->commentModel = new CommentModel($conn); $this->commentModel = new CommentModel($conn);
} }
public function getCommentsByTicketId($ticketId) { public function getCommentsByTicketId($ticketId)
{
return $this->commentModel->getCommentsByTicketId($ticketId); return $this->commentModel->getCommentsByTicketId($ticketId);
} }
public function addComment($ticketId) { public function addComment($ticketId)
{
// Check if this is an AJAX request // Check if this is an AJAX request
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Get JSON data // Get JSON data
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
// Validate input // Validate input
if (empty($data['comment_text'])) { if (empty($data['comment_text'])) {
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -27,10 +32,10 @@ class CommentController {
]); ]);
return; return;
} }
// Add comment // Add comment
$result = $this->commentModel->addComment($ticketId, $data); $result = $this->commentModel->addComment($ticketId, $data);
// Return JSON response // Return JSON response
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode($result); echo json_encode($result);
@@ -40,4 +45,4 @@ class CommentController {
exit; exit;
} }
} }
} }
+180 -45
View File
@@ -1,44 +1,115 @@
<?php <?php
require_once 'models/TicketModel.php'; require_once 'models/TicketModel.php';
require_once 'models/UserPreferencesModel.php'; require_once 'models/UserPreferencesModel.php';
require_once 'models/StatsModel.php';
class DashboardController { class DashboardController
{
private $ticketModel; private $ticketModel;
private $prefsModel; private $prefsModel;
private $statsModel;
private $conn; private $conn;
public function __construct($conn) { /** Valid sort columns (whitelist) */
private const VALID_SORT_COLUMNS = [
'ticket_id', 'title', 'status', 'priority', 'category', 'type',
'created_at', 'updated_at', 'assigned_to', 'created_by'
];
/** Valid statuses */
private const VALID_STATUSES = ['Open', 'Pending', 'In Progress', 'Closed'];
public function __construct($conn)
{
$this->conn = $conn; $this->conn = $conn;
$this->ticketModel = new TicketModel($conn); $this->ticketModel = new TicketModel($conn);
$this->prefsModel = new UserPreferencesModel($conn); $this->prefsModel = new UserPreferencesModel($conn);
$this->statsModel = new StatsModel($conn);
} }
public function index() { /**
* Validate and sanitize a date string
*/
private function validateDate(?string $date): ?string
{
if (empty($date)) {
return null;
}
// Check if it's a valid date format (YYYY-MM-DD)
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) && strtotime($date) !== false) {
return $date;
}
return null;
}
/**
* Validate priority value (1-5)
*/
private function validatePriority($priority): ?int
{
if ($priority === null || $priority === '') {
return null;
}
$val = (int)$priority;
return ($val >= 1 && $val <= 5) ? $val : null;
}
/**
* Validate user ID
*/
private function validateUserId($userId): ?int
{
if ($userId === null || $userId === '') {
return null;
}
$val = (int)$userId;
return ($val > 0) ? $val : null;
}
public function index()
{
// Get user ID for preferences // Get user ID for preferences
$userId = isset($_SESSION['user']['user_id']) ? $_SESSION['user']['user_id'] : null; $userId = isset($_SESSION['user']['user_id']) ? $_SESSION['user']['user_id'] : null;
// Get query parameters // Validate and sanitize page parameter
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1; $page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
// Get rows per page from user preferences, fallback to cookie, then default // Get rows per page from user preferences, fallback to cookie, then default
// Clamp to reasonable range (1-100)
$limit = 15; $limit = 15;
if ($userId) { if ($userId) {
$limit = (int)$this->prefsModel->getPreference($userId, 'rows_per_page', 15); $limit = (int)$this->prefsModel->getPreference($userId, 'rows_per_page', 15);
} else if (isset($_COOKIE['ticketsPerPage'])) { } elseif (isset($_COOKIE['ticketsPerPage'])) {
$limit = (int)$_COOKIE['ticketsPerPage']; $limit = (int)$_COOKIE['ticketsPerPage'];
} }
$limit = max(1, min(100, $limit));
$sortColumn = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id'; // Validate sort column against whitelist
$sortDirection = isset($_GET['dir']) ? $_GET['dir'] : 'desc'; $sortColumn = isset($_GET['sort']) && in_array($_GET['sort'], self::VALID_SORT_COLUMNS, true)
$category = isset($_GET['category']) ? $_GET['category'] : null; ? $_GET['sort']
$type = isset($_GET['type']) ? $_GET['type'] : null; : 'ticket_id';
$search = isset($_GET['search']) ? trim($_GET['search']) : null;
// Validate sort direction
$sortDirection = isset($_GET['dir']) && strtolower($_GET['dir']) === 'asc' ? 'asc' : 'desc';
// Category and type are validated by the model (uses prepared statements)
$category = isset($_GET['category']) ? trim($_GET['category']) : null;
$type = isset($_GET['type']) ? trim($_GET['type']) : null;
// Sanitize search - limit length to prevent abuse
$search = isset($_GET['search']) ? substr(trim($_GET['search']), 0, 255) : null;
// Handle status filtering with user preferences // Handle status filtering with user preferences
$status = null; $status = null;
if (isset($_GET['status']) && !empty($_GET['status'])) { if (isset($_GET['status']) && !empty($_GET['status'])) {
$status = $_GET['status']; // Validate each status in the comma-separated list
} else if (!isset($_GET['show_all'])) { $requestedStatuses = array_map('trim', explode(',', $_GET['status']));
$validStatuses = array_filter($requestedStatuses, function ($s) {
return in_array($s, self::VALID_STATUSES, true);
});
$status = !empty($validStatuses) ? implode(',', $validStatuses) : null;
} elseif (!isset($_GET['show_all'])) {
// Get default status filters from user preferences // Get default status filters from user preferences
if ($userId) { if ($userId) {
$status = $this->prefsModel->getPreference($userId, 'default_status_filters', 'Open,Pending,In Progress'); $status = $this->prefsModel->getPreference($userId, 'default_status_filters', 'Open,Pending,In Progress');
@@ -49,51 +120,115 @@ class DashboardController {
} }
// If $_GET['show_all'] exists or no status param with show_all, show all tickets (status = null) // If $_GET['show_all'] exists or no status param with show_all, show all tickets (status = null)
// Build advanced search filters array // Build and validate advanced search filters
$filters = []; $filters = [];
if (isset($_GET['created_from'])) $filters['created_from'] = $_GET['created_from'];
if (isset($_GET['created_to'])) $filters['created_to'] = $_GET['created_to']; // Validate date filters
if (isset($_GET['updated_from'])) $filters['updated_from'] = $_GET['updated_from']; $createdFrom = $this->validateDate($_GET['created_from'] ?? null);
if (isset($_GET['updated_to'])) $filters['updated_to'] = $_GET['updated_to']; $createdTo = $this->validateDate($_GET['created_to'] ?? null);
if (isset($_GET['priority_min'])) $filters['priority_min'] = $_GET['priority_min']; $updatedFrom = $this->validateDate($_GET['updated_from'] ?? null);
if (isset($_GET['priority_max'])) $filters['priority_max'] = $_GET['priority_max']; $updatedTo = $this->validateDate($_GET['updated_to'] ?? null);
if (isset($_GET['created_by'])) $filters['created_by'] = $_GET['created_by']; $closedFrom = $this->validateDate($_GET['closed_from'] ?? null);
if (isset($_GET['assigned_to'])) $filters['assigned_to'] = $_GET['assigned_to']; $closedTo = $this->validateDate($_GET['closed_to'] ?? null);
if ($createdFrom) {
$filters['created_from'] = $createdFrom;
}
if ($createdTo) {
$filters['created_to'] = $createdTo;
}
if ($updatedFrom) {
$filters['updated_from'] = $updatedFrom;
}
if ($updatedTo) {
$filters['updated_to'] = $updatedTo;
}
if ($closedFrom) {
$filters['closed_from'] = $closedFrom;
}
if ($closedTo) {
$filters['closed_to'] = $closedTo;
}
// Validate priority filters; ?priority=N sets exact match (min=max=N)
$prioritySingle = $this->validatePriority($_GET['priority'] ?? null);
$priorityMin = $prioritySingle ?? $this->validatePriority($_GET['priority_min'] ?? null);
$priorityMax = $prioritySingle ?? $this->validatePriority($_GET['priority_max'] ?? null);
if ($priorityMin !== null) {
$filters['priority_min'] = $priorityMin;
}
if ($priorityMax !== null) {
$filters['priority_max'] = $priorityMax;
}
// Validate user ID filters
$createdBy = $this->validateUserId($_GET['created_by'] ?? null);
if ($createdBy !== null) {
$filters['created_by'] = $createdBy;
}
// assigned_to accepts a numeric user ID, 'unassigned', or the special string 'me'
$assignedToRaw = $_GET['assigned_to'] ?? null;
if ($assignedToRaw === 'unassigned') {
$filters['assigned_to'] = 'unassigned';
} elseif ($assignedToRaw === 'me' && $userId) {
$filters['assigned_to'] = (int)$userId;
} else {
$assignedTo = $this->validateUserId($assignedToRaw);
if ($assignedTo !== null) {
$filters['assigned_to'] = $assignedTo;
}
}
// Get tickets with pagination, sorting, search, and advanced filters // Get tickets with pagination, sorting, search, and advanced filters
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters); $result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters, $GLOBALS['currentUser'] ?? []);
// Get categories and types for filters // Get categories and types for filters (single query)
$categories = $this->getCategories(); $filterOptions = $this->getCategoriesAndTypes();
$types = $this->getTypes(); $categories = $filterOptions['categories'];
$types = $filterOptions['types'];
// Extract data for the view // Extract data for the view
$tickets = $result['tickets']; $tickets = $result['tickets'];
$totalTickets = $result['total']; $totalTickets = $result['total'];
$totalPages = $result['pages']; $totalPages = $result['pages'];
// Load dashboard statistics
$stats = $this->statsModel->getAllStats($GLOBALS['currentUser'] ?? []);
// Load the dashboard view // Load the dashboard view
include 'views/DashboardView.php'; include 'views/DashboardView.php';
} }
private function getCategories() { /**
$sql = "SELECT DISTINCT category FROM tickets WHERE category IS NOT NULL ORDER BY category"; * Get categories and types in a single query
*
* @return array ['categories' => [...], 'types' => [...]]
*/
private function getCategoriesAndTypes(): array
{
$sql = "SELECT 'category' as field, category as value FROM tickets WHERE category IS NOT NULL
UNION
SELECT 'type' as field, type as value FROM tickets WHERE type IS NOT NULL
ORDER BY field, value";
$result = $this->conn->query($sql); $result = $this->conn->query($sql);
$categories = []; $categories = [];
while($row = $result->fetch_assoc()) {
$categories[] = $row['category'];
}
return $categories;
}
private function getTypes() {
$sql = "SELECT DISTINCT type FROM tickets WHERE type IS NOT NULL ORDER BY type";
$result = $this->conn->query($sql);
$types = []; $types = [];
while($row = $result->fetch_assoc()) {
$types[] = $row['type']; if (!$result) {
return ['categories' => $categories, 'types' => $types];
} }
return $types;
while ($row = $result->fetch_assoc()) {
if ($row['field'] === 'category' && !in_array($row['value'], $categories, true)) {
$categories[] = $row['value'];
} elseif ($row['field'] === 'type' && !in_array($row['value'], $types, true)) {
$types[] = $row['value'];
}
}
return ['categories' => $categories, 'types' => $types];
} }
} }
?>
+67 -169
View File
@@ -1,4 +1,5 @@
<?php <?php
// Use absolute paths for model includes // Use absolute paths for model includes
require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php'; require_once dirname(__DIR__) . '/models/CommentModel.php';
@@ -6,46 +7,32 @@ require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php'; require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php'; require_once dirname(__DIR__) . '/models/WorkflowModel.php';
require_once dirname(__DIR__) . '/models/TemplateModel.php'; require_once dirname(__DIR__) . '/models/TemplateModel.php';
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
class TicketController { class TicketController
{
private $ticketModel; private $ticketModel;
private $commentModel; private $commentModel;
private $auditLogModel; private $auditLogModel;
private $userModel; private $userModel;
private $workflowModel; private $workflowModel;
private $templateModel; private $templateModel;
private $envVars; private $conn;
public function __construct($conn) { public function __construct($conn)
{
$this->conn = $conn;
$this->ticketModel = new TicketModel($conn); $this->ticketModel = new TicketModel($conn);
$this->commentModel = new CommentModel($conn); $this->commentModel = new CommentModel($conn);
$this->auditLogModel = new AuditLogModel($conn); $this->auditLogModel = new AuditLogModel($conn);
$this->userModel = new UserModel($conn); $this->userModel = new UserModel($conn);
$this->workflowModel = new WorkflowModel($conn); $this->workflowModel = new WorkflowModel($conn);
$this->templateModel = new TemplateModel($conn); $this->templateModel = new TemplateModel($conn);
// Load environment variables for Discord webhook
$envPath = dirname(__DIR__) . '/.env';
$this->envVars = [];
if (file_exists($envPath)) {
$lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
list($key, $value) = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
// Remove surrounding quotes if present
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
$value = substr($value, 1, -1);
}
$this->envVars[$key] = $value;
}
}
}
} }
public function view($id) { public function view($id)
{
// Get current user // Get current user
$currentUser = $GLOBALS['currentUser'] ?? null; $currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null; $userId = $currentUser['user_id'] ?? null;
@@ -53,14 +40,16 @@ class TicketController {
// Get ticket data // Get ticket data
$ticket = $this->ticketModel->getTicketById($id); $ticket = $this->ticketModel->getTicketById($id);
if (!$ticket) { if (!$ticket || !$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) {
header("HTTP/1.0 404 Not Found"); http_response_code(404);
echo "Ticket not found"; include dirname(__DIR__) . '/views/error_404.php';
return; return;
} }
// Get comments for this ticket using CommentModel // Load first page of comments; show "load more" if ticket has many
$comments = $this->commentModel->getCommentsByTicketId($id); $commentPageSize = 50;
$totalComments = $this->commentModel->getCommentCount((int)$id);
$comments = $this->commentModel->getCommentsByTicketId($id, true, $commentPageSize, 0);
// Get timeline for this ticket // Get timeline for this ticket
$timeline = $this->auditLogModel->getTicketTimeline($id); $timeline = $this->auditLogModel->getTicketTimeline($id);
@@ -71,29 +60,56 @@ class TicketController {
// Get allowed status transitions for this ticket // Get allowed status transitions for this ticket
$allowedTransitions = $this->workflowModel->getAllowedTransitions($ticket['status']); $allowedTransitions = $this->workflowModel->getAllowedTransitions($ticket['status']);
// Make $conn available to view for visibility groups
$conn = $this->conn;
// Load the view // Load the view
include dirname(__DIR__) . '/views/TicketView.php'; include dirname(__DIR__) . '/views/TicketView.php';
} }
public function create() { public function create()
{
// Get current user // Get current user
$currentUser = $GLOBALS['currentUser'] ?? null; $currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null; $userId = $currentUser['user_id'] ?? null;
// Check if form was submitted // Check if form was submitted
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validate CSRF token
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_POST['csrf_token'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
$error = "Invalid or expired security token. Please try again.";
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn;
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
// Handle visibility groups (comes as array from checkboxes)
$visibilityGroups = null;
if (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) {
$visibilityGroups = implode(',', array_map('trim', $_POST['visibility_groups']));
}
$ticketData = [ $ticketData = [
'title' => $_POST['title'] ?? '', 'title' => $_POST['title'] ?? '',
'description' => $_POST['description'] ?? '', 'description' => $_POST['description'] ?? '',
'priority' => $_POST['priority'] ?? '4', 'priority' => $_POST['priority'] ?? '4',
'category' => $_POST['category'] ?? 'General', 'category' => $_POST['category'] ?? 'General',
'type' => $_POST['type'] ?? 'Issue' 'type' => $_POST['type'] ?? 'Issue',
'visibility' => $_POST['visibility'] ?? 'public',
'visibility_groups' => $visibilityGroups,
'assigned_to' => !empty($_POST['assigned_to']) ? $_POST['assigned_to'] : null
]; ];
// Validate input // Validate input
if (empty($ticketData['title'])) { if (empty($ticketData['title'])) {
$error = "Title is required"; $error = "Title is required";
$templates = $this->templateModel->getAllTemplates(); $templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn; // Make $conn available to view
include dirname(__DIR__) . '/views/CreateTicketView.php'; include dirname(__DIR__) . '/views/CreateTicketView.php';
return; return;
} }
@@ -107,8 +123,19 @@ class TicketController {
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData); $GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
} }
// Send Discord webhook notification for new ticket // Auto-link as duplicate if requested from create form
$this->sendDiscordWebhook($result['ticket_id'], $ticketData); $linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
$depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by)
VALUES (?, ?, 'duplicates', ?)";
$depStmt = $this->conn->prepare($depSql);
$depStmt->bind_param("ssi", $result['ticket_id'], $linkDupOfRaw, $userId);
$depStmt->execute();
$depStmt->close();
}
// Send Matrix notification for new ticket
NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual');
// Redirect to the new ticket // Redirect to the new ticket
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/" . $result['ticket_id']); header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/" . $result['ticket_id']);
@@ -116,149 +143,20 @@ class TicketController {
} else { } else {
$error = $result['error']; $error = $result['error'];
$templates = $this->templateModel->getAllTemplates(); $templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn; // Make $conn available to view
include dirname(__DIR__) . '/views/CreateTicketView.php'; include dirname(__DIR__) . '/views/CreateTicketView.php';
return; return;
} }
} else { } else {
// Get all templates for the template selector // Get all templates for the template selector
$templates = $this->templateModel->getAllTemplates(); $templates = $this->templateModel->getAllTemplates();
// Get all users for assignment dropdown
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn; // Make $conn available to view
// Display the create ticket form // Display the create ticket form
include dirname(__DIR__) . '/views/CreateTicketView.php'; include dirname(__DIR__) . '/views/CreateTicketView.php';
} }
} }
public function update($id) {
// Get current user
$currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null;
// Check if this is an AJAX request
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// For AJAX requests, get JSON data
$input = file_get_contents('php://input');
$data = json_decode($input, true);
// Add ticket_id to the data
$data['ticket_id'] = $id;
// Validate input data
if (empty($data['title'])) {
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'Title cannot be empty'
]);
return;
}
// Update ticket with user tracking
$result = $this->ticketModel->updateTicket($data, $userId);
// Log ticket update to audit log
if ($result && isset($GLOBALS['auditLog']) && $userId) {
$GLOBALS['auditLog']->logTicketUpdate($userId, $id, $data);
}
// Return JSON response
header('Content-Type: application/json');
if ($result) {
echo json_encode([
'success' => true,
'status' => $data['status']
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to update ticket'
]);
}
} else {
// For direct access, redirect to view
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/$id");
exit;
}
}
private function sendDiscordWebhook($ticketId, $ticketData) {
if (!isset($this->envVars['DISCORD_WEBHOOK_URL']) || empty($this->envVars['DISCORD_WEBHOOK_URL'])) {
error_log("Discord webhook URL not configured, skipping webhook for ticket creation");
return;
}
$webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL'];
// Create ticket URL
$ticketUrl = "http://t.lotusguild.org/ticket/$ticketId";
// Map priorities to Discord colors
$priorityColors = [
1 => 0xff4d4d, // Red
2 => 0xffa726, // Orange
3 => 0x42a5f5, // Blue
4 => 0x66bb6a, // Green
5 => 0x9e9e9e // Gray
];
$priority = (int)($ticketData['priority'] ?? 4);
$color = $priorityColors[$priority] ?? 0x3498db;
$embed = [
'title' => '🎫 New Ticket Created',
'description' => "**#{$ticketId}** - " . $ticketData['title'],
'url' => $ticketUrl,
'color' => $color,
'fields' => [
[
'name' => 'Priority',
'value' => 'P' . $priority,
'inline' => true
],
[
'name' => 'Category',
'value' => $ticketData['category'] ?? 'General',
'inline' => true
],
[
'name' => 'Type',
'value' => $ticketData['type'] ?? 'Issue',
'inline' => true
],
[
'name' => 'Status',
'value' => $ticketData['status'] ?? 'Open',
'inline' => true
]
],
'footer' => [
'text' => 'Tinker Tickets'
],
'timestamp' => date('c')
];
$payload = [
'embeds' => [$embed]
];
// Send webhook
$ch = curl_init($webhookUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$webhookResult = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
error_log("Discord webhook cURL error: $curlError");
} else {
error_log("Discord webhook sent for new ticket. HTTP Code: $httpCode");
}
}
} }
?>
+301 -112
View File
@@ -1,9 +1,9 @@
<?php <?php
header('Content-Type: application/json'); header('Content-Type: application/json');
error_reporting(E_ALL); error_reporting(E_ALL);
ini_set('display_errors', 1); ini_set('display_errors', 0);
file_put_contents('debug.log', print_r($_POST, true) . "\n" . file_get_contents('php://input') . "\n", FILE_APPEND);
// Load environment variables with error check // Load environment variables with error check
$envFile = __DIR__ . '/.env'; $envFile = __DIR__ . '/.env';
@@ -27,8 +27,10 @@ if (!$envVars) {
// Strip quotes from values if present (parse_ini_file may include them) // Strip quotes from values if present (parse_ini_file may include them)
foreach ($envVars as $key => $value) { foreach ($envVars as $key => $value) {
if (is_string($value)) { if (is_string($value)) {
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') || if (
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) { (substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
) {
$envVars[$key] = substr($value, 1, -1); $envVars[$key] = substr($value, 1, -1);
} }
} }
@@ -50,9 +52,13 @@ if ($conn->connect_error) {
exit; exit;
} }
// Load application config so UrlHelper can resolve APP_DOMAIN
require_once __DIR__ . '/config/config.php';
// Authenticate via API key // Authenticate via API key
require_once __DIR__ . '/middleware/ApiKeyAuth.php'; require_once __DIR__ . '/middleware/ApiKeyAuth.php';
require_once __DIR__ . '/models/AuditLogModel.php'; require_once __DIR__ . '/models/AuditLogModel.php';
require_once __DIR__ . '/helpers/UrlHelper.php';
$apiKeyAuth = new ApiKeyAuth($conn); $apiKeyAuth = new ApiKeyAuth($conn);
@@ -64,7 +70,6 @@ try {
} }
$userId = $systemUser['user_id']; $userId = $systemUser['user_id'];
file_put_contents('debug.log', "Authenticated as system user ID: $userId\n", FILE_APPEND);
// Create tickets table with hash column if not exists // Create tickets table with hash column if not exists
$createTableSQL = "CREATE TABLE IF NOT EXISTS tickets ( $createTableSQL = "CREATE TABLE IF NOT EXISTS tickets (
@@ -82,95 +87,296 @@ $conn->query($createTableSQL);
$rawInput = file_get_contents('php://input'); $rawInput = file_get_contents('php://input');
$data = json_decode($rawInput, true); $data = json_decode($rawInput, true);
// Generate hash from stable components // Validate required fields before any processing
function generateTicketHash($data) { if (!is_array($data) || empty($data['title'])) {
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns) // Try URL-encoded fallback
preg_match('/\/dev\/(sd[a-z]|nvme\d+n\d+)/', $data['title'], $deviceMatches); if (empty($data['title'])) {
$isDriveTicket = !empty($deviceMatches); parse_str($rawInput, $urlData);
if (!empty($urlData['title'])) {
$data = $urlData;
}
}
if (!is_array($data) || empty($data['title'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'title is required']);
exit;
}
}
// Extract hostname from title [hostname][tags]... // Generate hash from stable components
preg_match('/\[([\w\d-]+)\]/', $data['title'], $hostMatches); function generateTicketHash($data)
{
$title = (string)($data['title'] ?? '');
// Prefer explicit serial from payload; fall back to extracting device path from title
// for backwards compatibility with older hwmonDaemon versions.
$serial = isset($data['serial']) && $data['serial'] !== null && $data['serial'] !== ''
? (string)$data['serial']
: null;
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
preg_match('/\/dev\/(sd[a-z]+|nvme\d+n\d+)/', $title, $deviceMatches);
$isDriveTicket = !empty($deviceMatches) || $serial !== null;
// Extract first bracketed tag as hostname/source
preg_match('/^\[([^\]]+)\]/', $title, $hostMatches);
$hostname = $hostMatches[1] ?? ''; $hostname = $hostMatches[1] ?? '';
// Detect issue category (not specific attribute values) // Detect issue category and optional sub-type
$issueCategory = ''; $issueCategory = '';
if (stripos($data['title'], 'SMART issues') !== false) { $issueSubtype = '';
$isClusterWide = false;
if (stripos($title, 'SMART issues') !== false) {
$issueCategory = 'smart'; $issueCategory = 'smart';
} elseif (stripos($data['title'], 'LXC') !== false || stripos($data['title'], 'storage usage') !== false) { } 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'; $issueCategory = 'storage';
} elseif (stripos($data['title'], 'memory') !== false) { // Include the LXC container ID so each container gets its own ticket
if (preg_match('/LXC\s+(\d+)/i', $title, $lxcMatch)) {
$issueSubtype = 'lxc_' . $lxcMatch[1];
}
} elseif (stripos($title, 'memory') !== false) {
$issueCategory = 'memory'; $issueCategory = 'memory';
} elseif (stripos($data['title'], 'cpu') !== false) { } elseif (stripos($title, 'cpu') !== false) {
$issueCategory = 'cpu'; $issueCategory = 'cpu';
} elseif (stripos($data['title'], 'network') !== false) { } elseif (stripos($title, 'network') !== false) {
$issueCategory = 'network'; $issueCategory = 'network';
} elseif (stripos($title, 'Ceph') !== false || stripos($title, '[ceph]') !== false) {
$issueCategory = 'ceph';
if (
stripos($title, '[cluster-wide]') !== false ||
stripos($title, 'HEALTH_ERR') !== false ||
stripos($title, 'HEALTH_WARN') !== false ||
stripos($title, 'cluster usage') !== false
) {
$isClusterWide = true;
}
// Normalize the specific Ceph warning type so different warnings get distinct tickets
if (stripos($title, 'slow') !== false && stripos($title, 'BlueStore') !== false) {
$issueSubtype = 'bluestore_slow';
} elseif (stripos($title, 'clock skew') !== false) {
$issueSubtype = 'clock_skew';
} elseif (stripos($title, 'cluster usage') !== false) {
$issueSubtype = 'usage';
} 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];
} else {
$issueSubtype = 'osd_down';
}
} elseif (stripos($title, 'HEALTH_ERR') !== false) {
$issueSubtype = 'health_err';
}
} }
// Build stable components with only static data // Include source type so automated tickets never collide with manual ones
$sourceType = stripos($title, '[auto]') !== false ? 'auto' : 'manual';
// Build stable components
$stableComponents = [ $stableComponents = [
'hostname' => $hostname, 'source_type' => $sourceType,
'issue_category' => $issueCategory, // Generic category, not specific errors 'issue_category' => $issueCategory,
'environment_tags' => array_filter( 'issue_subtype' => $issueSubtype,
explode('][', $data['title']), 'environment_tags' => array_values(array_filter(
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node']) explode('][', $title),
) fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'])
)),
]; ];
// Only include device info for drive-specific tickets // Include hostname for node-specific issues
if ($isDriveTicket) { if (!$isClusterWide) {
$stableComponents['device'] = $deviceMatches[0]; $stableComponents['hostname'] = $hostname;
}
// Include drive identifier for drive-specific tickets.
// Use serial when available (stable across reboots/reshuffles); fall back to
// device path for tickets created before serial was added to the payload.
if ($isDriveTicket) {
$stableComponents['drive'] = $serial ?? ($deviceMatches[0] ?? '');
} }
// Sort arrays for consistent hashing
sort($stableComponents['environment_tags']); sort($stableComponents['environment_tags']);
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES)); return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
} }
// Check for duplicate tickets // Shared ticket data
$title = (string)($data['title'] ?? '');
$description = (string)($data['description'] ?? '');
$status = (string)($data['status'] ?? 'Open');
$priority = $data['priority'] ?? '4';
$category = (string)($data['category'] ?? 'General');
$type = (string)($data['type'] ?? 'Issue');
$ticketHash = generateTicketHash($data); $ticketHash = generateTicketHash($data);
$checkDuplicateSQL = "SELECT ticket_id FROM tickets WHERE hash = ? AND created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)"; $auditLog = new AuditLogModel($conn);
$checkStmt = $conn->prepare($checkDuplicateSQL);
// Look up any existing ticket with this hash (open OR closed)
$checkStmt = $conn->prepare("SELECT ticket_id, status, title, priority FROM tickets WHERE hash = ? ORDER BY created_at DESC LIMIT 1");
$checkStmt->bind_param("s", $ticketHash); $checkStmt->bind_param("s", $ticketHash);
$checkStmt->execute(); $checkStmt->execute();
$result = $checkStmt->get_result(); $existing = $checkStmt->get_result()->fetch_assoc();
$checkStmt->close();
if ($existing) {
$existingId = $existing['ticket_id'];
$existingStatus = $existing['status'];
$existingTitle = $existing['title'];
$existingPriority = (int)$existing['priority'];
$newPriority = (int)$priority;
if ($existingStatus !== 'Closed') {
// Ticket is still active — update title, escalate priority, and refresh
// description with latest sensor data.
$changes = [];
$updateSql = "UPDATE tickets SET updated_at = NOW(), updated_by = ?";
$bindTypes = "i";
$bindVals = [$userId];
if ($title !== $existingTitle) {
$updateSql .= ", title = ?";
$bindTypes .= "s";
$bindVals[] = $title;
$changes['title'] = ['from' => $existingTitle, 'to' => $title];
}
if ($newPriority < $existingPriority) {
$updateSql .= ", priority = ?";
$bindTypes .= "i";
$bindVals[] = $newPriority;
$changes['priority'] = ['from' => $existingPriority, 'to' => $newPriority];
}
// Always refresh the description so the ticket body shows current sensor data
if (!empty($description)) {
$updateSql .= ", description = ?";
$bindTypes .= "s";
$bindVals[] = $description;
$changes['description_refreshed'] = true;
}
if (!empty($changes)) {
$updateSql .= " WHERE ticket_id = ?";
$bindTypes .= "s";
$bindVals[] = $existingId;
$updStmt = $conn->prepare($updateSql);
$updStmt->bind_param($bindTypes, ...$bindVals);
$updStmt->execute();
$updStmt->close();
// Only post a comment on priority escalation — title and description updates
// are silent (title changes like rising counters would spam a comment every run)
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)"
);
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
$commentStmt->execute();
$commentStmt->close();
}
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
array_diff_key($changes, ['description_refreshed' => true]),
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
));
// Only notify on priority escalation — title-only updates (e.g. rising
// Power_On_Hours counter) should not generate a Matrix ping every hour.
if (isset($changes['priority'])) {
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($existingId, [
'title' => $title,
'priority' => $changes['priority']['to'],
'category' => $category,
'type' => $type,
'status' => $existingStatus,
], 'automated');
}
}
$conn->close();
echo json_encode([
'success' => true,
'ticket_id' => $existingId,
'message' => empty($changes) ? 'Duplicate — no change' : 'Existing ticket updated',
'action' => empty($changes) ? 'deduplicated' : 'updated',
'changes' => $changes,
]);
exit;
}
// Ticket was closed — reopen it and add a recurrence comment
$reopenStmt = $conn->prepare(
"UPDATE tickets SET status = 'Open', closed_at = NULL, updated_at = NOW(), updated_by = ? WHERE ticket_id = ?"
);
$reopenStmt->bind_param("is", $userId, $existingId);
$reopenStmt->execute();
$reopenStmt->close();
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
"New report received from hwmonDaemon:\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)"
);
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
$commentStmt->execute();
$commentStmt->close();
$auditLog->log($userId, 'update', 'ticket', $existingId, [
'status' => ['from' => 'Closed', 'to' => 'Open'],
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
]);
$conn->close();
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($existingId, [
'title' => $title,
'priority' => $priority,
'category' => $category,
'type' => $type,
'status' => 'Open',
], 'automated');
if ($result->num_rows > 0) {
$existingTicket = $result->fetch_assoc();
echo json_encode([ echo json_encode([
'success' => false, 'success' => true,
'error' => 'Duplicate ticket', 'ticket_id' => $existingId,
'existing_ticket_id' => $existingTicket['ticket_id'] 'message' => 'Existing closed ticket reopened',
'action' => 'reopened',
]); ]);
exit; exit;
} }
// Force JSON content type for all incoming requests // No existing ticket — create a new one
header('Content-Type: application/json'); // Use random_int range 100000000-999999999 to avoid leading-zero IDs
try {
if (!$data) { $ticket_id = (string)random_int(100000000, 999999999);
// Try parsing as URL-encoded data } catch (Exception $e) {
parse_str($rawInput, $data); $ticket_id = (string)mt_rand(100000000, 999999999);
} }
$insertStmt = $conn->prepare(
// Generate ticket ID (9-digit format with leading zeros) "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
$ticket_id = sprintf('%09d', mt_rand(1, 999999999)); VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
);
// Prepare insert query with created_by field $insertStmt->bind_param(
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $conn->prepare($sql);
// First, store all values in variables
$title = $data['title'];
$description = $data['description'];
$status = $data['status'] ?? 'Open';
$priority = $data['priority'] ?? '4';
$category = $data['category'] ?? 'General';
$type = $data['type'] ?? 'Issue';
// Then use the variables in bind_param
$stmt->bind_param(
"ssssssssi", "ssssssssi",
$ticket_id, $ticket_id,
$title, $title,
@@ -183,61 +389,44 @@ $stmt->bind_param(
$userId $userId
); );
if ($stmt->execute()) { try {
// Log ticket creation to audit log $inserted = $insertStmt->execute();
$auditLog = new AuditLogModel($conn); } catch (mysqli_sql_exception $e) {
$insertStmt->close();
if ($e->getCode() === 1062) {
// 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()]);
}
exit;
}
$insertStmt->close();
if ($inserted) {
$auditLog->logTicketCreate($userId, $ticket_id, [ $auditLog->logTicketCreate($userId, $ticket_id, [
'title' => $title, 'title' => $title,
'priority' => $priority, 'priority' => $priority,
'category' => $category, 'category' => $category,
'type' => $type 'type' => $type,
]); ]);
$conn->close();
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($ticket_id, [
'title' => $title,
'priority' => $priority,
'category' => $category,
'type' => $type,
'status' => $status,
], 'automated');
echo json_encode([ echo json_encode([
'success' => true, 'success' => true,
'ticket_id' => $ticket_id, 'ticket_id' => $ticket_id,
'message' => 'Ticket created successfully' 'message' => 'Ticket created successfully',
]); ]);
} else { } else {
echo json_encode([ echo json_encode(['success' => false, 'error' => $conn->error]);
'success' => false,
'error' => $conn->error
]);
} }
$stmt->close();
$conn->close();
// Discord webhook
$discord_webhook_url = $envVars['DISCORD_WEBHOOK_URL'];
// Map priorities to Discord colors (decimal format)
$priorityColors = [
"1" => 16736589, // --priority-1: #ff4d4d
"2" => 16753958, // --priority-2: #ffa726
"3" => 4363509, // --priority-3: #42a5f5
"4" => 6736490 // --priority-4: #66bb6a
];
$discord_data = [
"content" => "",
"embeds" => [[
"title" => "New Ticket Created: #" . $ticket_id,
"description" => $title,
"url" => "http://t.lotusguild.org/ticket/" . $ticket_id,
"color" => $priorityColors[$priority],
"fields" => [
["name" => "Priority", "value" => $priority, "inline" => true],
["name" => "Category", "value" => $category, "inline" => true],
["name" => "Type", "value" => $type, "inline" => true]
]
]]
];
$ch = curl_init($discord_webhook_url);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($discord_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_exec($ch);
curl_close($ch);
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env php
<?php
/**
* Rate Limit Cleanup Cron Job
*
* 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
*
* This script can also be run manually for immediate cleanup .
* /
// Prevent web access
if (php_sapi_name() !== 'cli') {
http_response_code(403);
exit('CLI access only');
}
// Configuration
$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
$lockFile = $rateLimitDir . '/.cleanup.lock';
$maxAge = 120; // 2 minutes (2x the rate limit window)
$maxLockAge = 300; // 5 minutes - release stale locks
// Check if directory exists
if (!is_dir($rateLimitDir)) {
echo "Rate limit directory does not exist: {$rateLimitDir}\n";
exit(0);
}
// Acquire lock to prevent concurrent cleanups
if (file_exists($lockFile)) {
$lockAge = time() - filemtime($lockFile);
if ($lockAge < $maxLockAge) {
echo "Cleanup already in progress (lock age: {$lockAge}s)\n";
exit(0);
}
// Stale lock, remove it
@unlink($lockFile);
}
// Create lock file
if (!@touch($lockFile)) {
echo "Could not create lock file\n";
exit(1);
}
$now = time();
$deleted = 0;
$scanned = 0;
$errors = 0;
try {
$iterator = new DirectoryIterator($rateLimitDir);
foreach ($iterator as $file) {
if ($file->isDot() || !$file->isFile()) {
continue;
}
// Skip lock file and non-json files
$filename = $file->getFilename();
if ($filename === '.cleanup.lock' || !str_ends_with($filename, '.json')) {
continue;
}
$scanned++;
// Check file age
$fileAge = $now - $file->getMTime();
if ($fileAge > $maxAge) {
$filepath = $file->getPathname();
if (@unlink($filepath)) {
$deleted++;
} else {
$errors++;
}
}
}
} catch (Exception $e) {
echo "Error during cleanup: " . $e->getMessage() . "\n";
@unlink($lockFile);
exit(1);
}
// Release lock
@unlink($lockFile);
// Output results
echo "Rate limit cleanup completed:\n";
echo " - Scanned: {$scanned} files\n";
echo " - Deleted: {$deleted} expired files\n";
if ($errors > 0) {
echo " - Errors: {$errors} files could not be deleted\n";
}
exit($errors > 0 ? 1 : 0);
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env php
<?php
/**
* Recurring Tickets Cron Job
*
* Run this script via cron to automatically create tickets from recurring schedules.
* 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
* /
// Change to project root directory
chdir(dirname(__DIR__));
// Include required files
require_once 'config/config.php';
require_once 'models/RecurringTicketModel.php';
require_once 'models/TicketModel.php';
require_once 'models/AuditLogModel.php';
// Log function
function logMessage($message)
{
echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n";
}
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);
}
// Initialize models
$recurringModel = new RecurringTicketModel($conn);
$ticketModel = new TicketModel($conn);
$auditLog = new AuditLogModel($conn);
// Get all due recurring tickets
$dueTickets = $recurringModel->getDueRecurringTickets();
logMessage("Found " . count($dueTickets) . " recurring tickets due for creation");
$created = 0;
$errors = 0;
foreach ($dueTickets as $recurring) {
logMessage("Processing recurring ticket ID: " . $recurring['recurring_id']);
try {
// Prepare ticket data
$ticketData = [
'title' => processTemplate($recurring['title_template']),
'description' => processTemplate($recurring['description_template']),
'category' => $recurring['category'],
'type' => $recurring['type'],
'priority' => $recurring['priority'],
'status' => 'Open'
];
// Create the ticket
$result = $ticketModel->createTicket($ticketData, $recurring['created_by']);
if ($result['success']) {
$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']);
}
// Log to audit
$auditLog->log(
$recurring['created_by'],
'create',
'ticket',
$ticketId,
['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'));
$errors++;
}
} catch (Exception $e) {
logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage());
$errors++;
}
}
logMessage("Completed: Created $created tickets, $errors errors");
$conn->close();
} catch (Exception $e) {
logMessage("FATAL ERROR: " . $e->getMessage());
exit(1);
}
/**
* Process template variables
*/
function processTemplate($template)
{
if (empty($template)) {
return $template;
}
$replacements = [
'{{date}}' => date('Y-m-d'),
'{{time}}' => date('H:i:s'),
'{{datetime}}' => date('Y-m-d H:i:s'),
'{{week}}' => date('W'),
'{{month}}' => date('F'),
'{{year}}' => date('Y'),
'{{day_of_week}}' => date('l'),
'{{day}}' => date('d'),
];
return str_replace(array_keys($replacements), array_values($replacements), $template);
}
logMessage("Cron job finished");
-15
View File
@@ -1,15 +0,0 @@
#!/bin/bash
set -e
echo "Deploying tinker_tickets to web server..."
# Deploy to web server
echo "Syncing to web server (10.10.10.45)..."
rsync -avz --delete --exclude='.git' --exclude='deploy.sh' --exclude='.env' ./ root@10.10.10.45:/var/www/html/tinkertickets/
# Set proper permissions on the web server
echo "Setting proper file permissions..."
ssh root@10.10.10.45 "chown -R www-data:www-data /var/www/html/tinkertickets && find /var/www/html/tinkertickets -type f -exec chmod 644 {} \; && find /var/www/html/tinkertickets -type d -exec chmod 755 {} \;"
echo "Deployment to web server complete!"
echo "Don't forget to commit and push your changes via VS Code when ready."
+7 -1
View File
@@ -1,4 +1,5 @@
<?php <?php
/** /**
* API Key Generator for hwmonDaemon * API Key Generator for hwmonDaemon
* Run this script once after migrations to generate the API key * Run this script once after migrations to generate the API key
@@ -6,6 +7,12 @@
* Usage: php generate_api_key.php * Usage: php generate_api_key.php
*/ */
// Prevent web access
if (php_sapi_name() !== 'cli') {
http_response_code(403);
exit('CLI access only');
}
require_once __DIR__ . '/config/config.php'; require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/models/ApiKeyModel.php'; require_once __DIR__ . '/models/ApiKeyModel.php';
require_once __DIR__ . '/models/UserModel.php'; require_once __DIR__ . '/models/UserModel.php';
@@ -98,4 +105,3 @@ $conn->close();
echo "Done! Delete this script after use:\n"; echo "Done! Delete this script after use:\n";
echo " rm " . __FILE__ . "\n\n"; echo " rm " . __FILE__ . "\n\n";
?>
+201
View File
@@ -0,0 +1,201 @@
<?php
/**
* Simple File-Based Cache Helper
*
* Provides caching for frequently accessed data that doesn't change often,
* such as workflow rules, user preferences, and configuration data.
*/
class CacheHelper
{
private static ?string $cacheDir = null;
private static array $memoryCache = [];
/**
* Get the cache directory path
*
* @return string Cache directory path
*/
private static function getCacheDir(): string
{
if (self::$cacheDir === null) {
self::$cacheDir = sys_get_temp_dir() . '/tinker_tickets_cache';
if (!is_dir(self::$cacheDir)) {
mkdir(self::$cacheDir, 0755, true);
}
}
return self::$cacheDir;
}
/**
* Generate a cache key from components
*
* @param string $prefix Cache prefix (e.g., 'workflow', 'user_prefs')
* @param mixed $identifier Unique identifier
* @return string Cache key
*/
private static function makeKey(string $prefix, $identifier = null): string
{
$key = $prefix;
if ($identifier !== null) {
$key .= '_' . md5(serialize($identifier));
}
return preg_replace('/[^a-zA-Z0-9_]/', '_', $key);
}
/**
* Get cached data
*
* @param string $prefix Cache prefix
* @param mixed $identifier Unique identifier
* @param int $ttl Time-to-live in seconds (default 300 = 5 minutes)
* @return mixed|null Cached data or null if not found/expired
*/
public static function get(string $prefix, $identifier = null, int $ttl = 300)
{
$key = self::makeKey($prefix, $identifier);
// Check memory cache first (fastest)
if (isset(self::$memoryCache[$key])) {
$cached = self::$memoryCache[$key];
if (time() - $cached['time'] < $ttl) {
return $cached['data'];
}
unset(self::$memoryCache[$key]);
}
// Check file cache
$filePath = self::getCacheDir() . '/' . $key . '.json';
if (file_exists($filePath)) {
$content = @file_get_contents($filePath);
if ($content !== false) {
$cached = json_decode($content, true);
if ($cached && isset($cached['time']) && isset($cached['data'])) {
if (time() - $cached['time'] < $ttl) {
// Store in memory cache for faster subsequent access
self::$memoryCache[$key] = $cached;
return $cached['data'];
}
}
}
// Expired - delete file
@unlink($filePath);
}
return null;
}
/**
* Store data in cache
*
* @param string $prefix Cache prefix
* @param mixed $identifier Unique identifier
* @param mixed $data Data to cache
* @return bool Success
*/
public static function set(string $prefix, $identifier, $data): bool
{
$key = self::makeKey($prefix, $identifier);
$cached = [
'time' => time(),
'data' => $data
];
// Store in memory cache
self::$memoryCache[$key] = $cached;
// Store in file cache
$filePath = self::getCacheDir() . '/' . $key . '.json';
return @file_put_contents($filePath, json_encode($cached), LOCK_EX) !== false;
}
/**
* Delete cached data
*
* @param string $prefix Cache prefix
* @param mixed $identifier Unique identifier (null to delete all with prefix)
* @return bool Success
*/
public static function delete(string $prefix, $identifier = null): bool
{
if ($identifier !== null) {
$key = self::makeKey($prefix, $identifier);
unset(self::$memoryCache[$key]);
$filePath = self::getCacheDir() . '/' . $key . '.json';
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);
foreach ($files as $file) {
@unlink($file);
}
// Clear memory cache entries with this prefix
foreach (array_keys(self::$memoryCache) as $key) {
if (strpos($key, $prefix) === 0) {
unset(self::$memoryCache[$key]);
}
}
return true;
}
/**
* Clear all cache
*
* @return bool Success
*/
public static function clearAll(): bool
{
self::$memoryCache = [];
$files = glob(self::getCacheDir() . '/*.json');
foreach ($files as $file) {
@unlink($file);
}
return true;
}
/**
* Get data from cache or fetch it using a callback
*
* @param string $prefix Cache prefix
* @param mixed $identifier Unique identifier
* @param callable $callback Function to call if cache miss
* @param int $ttl Time-to-live in seconds
* @return mixed Cached or freshly fetched data
*/
public static function remember(string $prefix, $identifier, callable $callback, int $ttl = 300)
{
$data = self::get($prefix, $identifier, $ttl);
if ($data === null) {
$data = $callback();
if ($data !== null) {
self::set($prefix, $identifier, $data);
}
}
return $data;
}
/**
* Clean up expired cache files (call periodically)
*
* @param int $maxAge Maximum age in seconds (default 1 hour)
*/
public static function cleanup(int $maxAge = 3600): void
{
$files = glob(self::getCacheDir() . '/*.json');
$now = time();
foreach ($files as $file) {
if ($now - filemtime($file) > $maxAge) {
@unlink($file);
}
}
}
}
+177
View File
@@ -0,0 +1,177 @@
<?php
/**
* Database Connection Factory
*
* Centralizes database connection creation and management.
* Provides a singleton connection for the request lifecycle.
*/
class Database
{
private static ?mysqli $connection = null;
/**
* Get database connection (singleton pattern)
*
* @return mysqli Database connection
* @throws Exception If connection fails
*/
public static function getConnection(): mysqli
{
if (self::$connection === null) {
self::$connection = self::createConnection();
}
// Check if connection is still alive
if (!self::$connection->ping()) {
self::$connection = self::createConnection();
}
return self::$connection;
}
/**
* Create a new database connection
*
* @return mysqli Database connection
* @throws Exception If connection fails
*/
private static function createConnection(): mysqli
{
// Ensure config is loaded
if (!isset($GLOBALS['config'])) {
require_once dirname(__DIR__) . '/config/config.php';
}
$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);
}
// Set charset to utf8mb4 for proper Unicode support
$conn->set_charset('utf8mb4');
return $conn;
}
/**
* Close the database connection
*/
public static function close(): void
{
if (self::$connection !== null) {
self::$connection->close();
self::$connection = null;
}
}
/**
* Begin a transaction
*
* @return bool Success
*/
public static function beginTransaction(): bool
{
return self::getConnection()->begin_transaction();
}
/**
* Commit a transaction
*
* @return bool Success
*/
public static function commit(): bool
{
return self::getConnection()->commit();
}
/**
* Rollback a transaction
*
* @return bool Success
*/
public static function rollback(): bool
{
return self::getConnection()->rollback();
}
/**
* Execute a query and return results
*
* @param string $sql SQL query with placeholders
* @param string $types Parameter types (i=int, s=string, d=double, b=blob)
* @param array $params Parameters to bind
* @return mysqli_result|bool Query result
*/
public static function query(string $sql, string $types = '', array $params = [])
{
$conn = self::getConnection();
if (empty($types) || empty($params)) {
return $conn->query($sql);
}
$stmt = $conn->prepare($sql);
if (!$stmt) {
throw new Exception("Query preparation failed: " . $conn->error);
}
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
$stmt->close();
return $result;
}
/**
* Execute an INSERT/UPDATE/DELETE and return affected rows
*
* @param string $sql SQL query with placeholders
* @param string $types Parameter types
* @param array $params Parameters to bind
* @return int Affected rows (-1 on failure)
*/
public static function execute(string $sql, string $types = '', array $params = []): int
{
$conn = self::getConnection();
$stmt = $conn->prepare($sql);
if (!$stmt) {
throw new Exception("Query preparation failed: " . $conn->error);
}
if (!empty($types) && !empty($params)) {
$stmt->bind_param($types, ...$params);
}
if ($stmt->execute()) {
$affected = $stmt->affected_rows;
$stmt->close();
return $affected;
}
$error = $stmt->error;
$stmt->close();
throw new Exception("Query execution failed: " . $error);
}
/**
* Get the last insert ID
*
* @return int Last insert ID
*/
public static function lastInsertId(): int
{
return self::getConnection()->insert_id;
}
// escape() removed — use prepared statements with bind_param() instead
}
+277
View File
@@ -0,0 +1,277 @@
<?php
/**
* Centralized Error Handler
*
* Provides consistent error handling, logging, and response formatting
* across the application.
*/
class ErrorHandler
{
private static ?string $logFile = null;
private static bool $initialized = false;
/**
* Initialize error handling
*
* @param bool $displayErrors Whether to display errors (false in production)
*/
public static function init(bool $displayErrors = false): void
{
if (self::$initialized) {
return;
}
// Set error reporting
error_reporting(E_ALL);
ini_set('display_errors', $displayErrors ? '1' : '0');
ini_set('log_errors', '1');
// Set up log file
self::$logFile = sys_get_temp_dir() . '/tinker_tickets_errors.log';
ini_set('error_log', self::$logFile);
// Register handlers
set_error_handler([self::class, 'handleError']);
set_exception_handler([self::class, 'handleException']);
register_shutdown_function([self::class, 'handleShutdown']);
self::$initialized = true;
}
/**
* Handle PHP errors
*
* @param int $errno Error level
* @param string $errstr Error message
* @param string $errfile File where error occurred
* @param int $errline Line number
* @return bool
*/
public static function handleError(int $errno, string $errstr, string $errfile, int $errline): bool
{
// Don't handle suppressed errors
if (!(error_reporting() & $errno)) {
return false;
}
$errorType = self::getErrorTypeName($errno);
$message = "$errorType: $errstr in $errfile on line $errline";
self::log($message, $errno);
// For fatal errors, throw exception
if (in_array($errno, [E_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR])) {
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
}
return true;
}
/**
* Handle uncaught exceptions
*
* @param Throwable $exception
*/
public static function handleException(Throwable $exception): void
{
$message = sprintf(
"Uncaught %s: %s in %s on line %d\nStack trace:\n%s",
get_class($exception),
$exception->getMessage(),
$exception->getFile(),
$exception->getLine(),
$exception->getTraceAsString()
);
self::log($message, E_ERROR);
// Send error response if headers not sent
if (!headers_sent()) {
self::sendErrorResponse(
'An unexpected error occurred',
500,
$exception
);
}
}
/**
* Handle fatal errors on shutdown
*/
public static function handleShutdown(): void
{
$error = error_get_last();
if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
$message = sprintf(
"Fatal Error: %s in %s on line %d",
$error['message'],
$error['file'],
$error['line']
);
self::log($message, E_ERROR);
if (!headers_sent()) {
self::sendErrorResponse('A fatal error occurred', 500);
}
}
}
/**
* Log an error message
*
* @param string $message Error message
* @param int $level Error level
* @param array $context Additional context
*/
public static function log(string $message, int $level = E_USER_NOTICE, array $context = []): void
{
$timestamp = date('Y-m-d H:i:s');
$levelName = self::getErrorTypeName($level);
$logMessage = "[$timestamp] [$levelName] $message";
if (!empty($context)) {
$logMessage .= " | Context: " . json_encode($context);
}
error_log($logMessage);
}
/**
* Send a JSON error response
*
* @param string $message User-facing error message
* @param int $httpCode HTTP status code
* @param Throwable|null $exception Original exception (for debug info)
*/
public static function sendErrorResponse(string $message, int $httpCode = 500, ?Throwable $exception = null): void
{
http_response_code($httpCode);
if (!headers_sent()) {
header('Content-Type: application/json');
}
$response = [
'success' => false,
'error' => $message
];
// Add debug info in development (check for debug mode)
if (isset($GLOBALS['config']['DEBUG']) && $GLOBALS['config']['DEBUG'] && $exception) {
$response['debug'] = [
'type' => get_class($exception),
'message' => $exception->getMessage(),
'file' => $exception->getFile(),
'line' => $exception->getLine()
];
}
echo json_encode($response);
exit;
}
/**
* Send a validation error response
*
* @param array $errors Array of validation errors
* @param string $message Overall error message
*/
public static function sendValidationError(array $errors, string $message = 'Validation failed'): void
{
http_response_code(422);
if (!headers_sent()) {
header('Content-Type: application/json');
}
echo json_encode([
'success' => false,
'error' => $message,
'validation_errors' => $errors
]);
exit;
}
/**
* Send a not found error response
*
* @param string $message Error message
*/
public static function sendNotFoundError(string $message = 'Resource not found'): void
{
self::sendErrorResponse($message, 404);
}
/**
* Send an unauthorized error response
*
* @param string $message Error message
*/
public static function sendUnauthorizedError(string $message = 'Authentication required'): void
{
self::sendErrorResponse($message, 401);
}
/**
* Send a forbidden error response
*
* @param string $message Error message
*/
public static function sendForbiddenError(string $message = 'Access denied'): void
{
self::sendErrorResponse($message, 403);
}
/**
* Get error type name from error number
*
* @param int $errno Error number
* @return string Error type name
*/
private static function getErrorTypeName(int $errno): string
{
$types = [
E_ERROR => 'ERROR',
E_WARNING => 'WARNING',
E_PARSE => 'PARSE',
E_NOTICE => 'NOTICE',
E_CORE_ERROR => 'CORE_ERROR',
E_CORE_WARNING => 'CORE_WARNING',
E_COMPILE_ERROR => 'COMPILE_ERROR',
E_COMPILE_WARNING => 'COMPILE_WARNING',
E_USER_ERROR => 'USER_ERROR',
E_USER_WARNING => 'USER_WARNING',
E_USER_NOTICE => 'USER_NOTICE',
E_STRICT => 'STRICT',
E_RECOVERABLE_ERROR => 'RECOVERABLE_ERROR',
E_DEPRECATED => 'DEPRECATED',
E_USER_DEPRECATED => 'USER_DEPRECATED',
];
return $types[$errno] ?? 'UNKNOWN';
}
/**
* Get recent error log entries
*
* @param int $lines Number of lines to return
* @return array Log entries
*/
public static function getRecentErrors(int $lines = 50): array
{
if (self::$logFile === null || !file_exists(self::$logFile)) {
return [];
}
$file = file(self::$logFile);
if ($file === false) {
return [];
}
return array_slice($file, -$lines);
}
}
+240
View File
@@ -0,0 +1,240 @@
<?php
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
class NotificationHelper
{
// ─── Internal: fire a webhook ─────────────────────────────────────────────
private static function fire(array $payload): void
{
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
if (empty($webhookUrl)) {
return;
}
$ch = curl_init($webhookUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
$id = $payload['ticket_id'] ?? '?';
if ($curlError) {
error_log("Matrix webhook cURL error for ticket #{$id}: {$curlError}");
} elseif ($httpCode < 200 || $httpCode >= 300) {
error_log("Matrix webhook failed for ticket #{$id}. HTTP {$httpCode}: {$response}");
}
}
private static function notifyUsers(): array
{
$raw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
return array_values(array_filter(array_map('trim', explode(',', $raw))));
}
// ─── Public event methods ─────────────────────────────────────────────────
/**
* New ticket created (manual or automated/API).
*/
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void
{
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
self::fire([
'event' => 'ticket_created',
'ticket_id' => $ticketId,
'title' => $ticketData['title'] ?? 'Untitled',
'priority' => (int)($ticketData['priority'] ?? 4),
'category' => $ticketData['category'] ?? 'General',
'type' => $ticketData['type'] ?? 'Issue',
'status' => $ticketData['status'] ?? 'Open',
'source' => $source,
'url' => UrlHelper::ticketUrl($ticketId),
'trigger' => $trigger,
'notify_users' => self::notifyUsers(),
]);
}
/**
* Ticket status changed.
*
* @param string|int $ticketId
* @param string $oldStatus
* @param string $newStatus
* @param string $ticketTitle
* @param string|null $changedByDisplay Display name of the user who changed status
*/
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null): void
{
self::fire([
'event' => 'status_changed',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'old_status' => $oldStatus,
'new_status' => $newStatus,
'changed_by' => $changedByDisplay,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => self::notifyUsers(),
]);
}
/**
* New comment posted (non-mention; use sendMentionNotification for @mentions).
*
* @param string|int $ticketId
* @param string $ticketTitle
* @param string $commentText Plain text (first 200 chars will be sent)
* @param string|null $authorDisplay Display name of commenter
* @param bool $isInternal True if the comment is internal-only
*/
public static function sendCommentNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay = null, bool $isInternal = false): void
{
// Skip if this is an internal-only comment — only the assignee/admin need to know
$notifyUsers = self::notifyUsers();
if (empty($notifyUsers)) {
return;
}
self::fire([
'event' => 'comment_added',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'author' => $authorDisplay,
'preview' => mb_strimwidth($commentText, 0, 200, '…'),
'is_internal' => $isInternal,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => $notifyUsers,
]);
}
/**
* @mention detected in a comment.
*
* @param string|int $ticketId
* @param string $ticketTitle
* @param string $commentText
* @param string|null $authorDisplay
* @param array $mentionedMatrixIds Matrix user IDs derived from @usernames
*/
public static function sendMentionNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay, array $mentionedMatrixIds): void
{
if (empty($mentionedMatrixIds)) {
return;
}
self::fire([
'event' => 'mention',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'author' => $authorDisplay,
'preview' => mb_strimwidth($commentText, 0, 200, '…'),
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => $mentionedMatrixIds,
]);
}
/**
* Notify all watchers of a ticket about an update event.
*
* Fetches watchers from the DB, resolves their Matrix IDs via Synapse,
* and fires the appropriate event notification with them in notify_users.
*
* @param \mysqli $conn
* @param string|int $ticketId
* @param string $ticketTitle
* @param string $event One of: status_changed, comment_added, assigned
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
* @param int|null $excludeUserId Don't notify the actor themselves
*/
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null): void
{
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
if (!$webhookUrl || !$domain) {
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();
$usernames = [];
while ($row = $result->fetch_assoc()) {
$usernames[] = $row['username'];
}
if (empty($usernames)) {
return;
}
// Resolve to Matrix IDs — skip users without Synapse accounts
$matrixIds = SynapseHelper::resolveUsernames($usernames);
if (empty($matrixIds)) {
return;
}
// Remove the global notify list duplicates and build payload
$allNotify = array_unique(array_merge($matrixIds, self::notifyUsers()));
$payload = array_merge($extraData, [
'event' => $event,
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => array_values($allNotify),
]);
self::fire($payload);
}
/**
* Ticket assigned (or reassigned) to a user.
*
* @param string|int $ticketId
* @param string $ticketTitle
* @param string|null $assigneeName Display name of new assignee
* @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM)
* @param string|null $changedByDisplay
*/
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null): void
{
$notifyUsers = self::notifyUsers();
// Also notify the assignee directly if we know their Matrix ID
if ($assigneeMatrix && !in_array($assigneeMatrix, $notifyUsers, true)) {
$notifyUsers[] = $assigneeMatrix;
}
if (empty($notifyUsers)) {
return;
}
self::fire([
'event' => 'assigned',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'assignee' => $assigneeName,
'changed_by' => $changedByDisplay,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => $notifyUsers,
]);
}
}
+212
View File
@@ -0,0 +1,212 @@
<?php
/**
* OutputHelper - Consistent output escaping utilities
*
* Provides secure HTML escaping functions to prevent XSS attacks.
* Use these functions when outputting user-controlled data.
*/
class OutputHelper
{
/**
* Escape string for HTML output
*
* Use for text content inside HTML elements.
* Example: <p><?= OutputHelper::h($userInput) ?></p>
*
* @param string|null $string The string to escape
* @param int $flags htmlspecialchars flags (default: ENT_QUOTES | ENT_HTML5)
* @return string Escaped string
*/
public static function h(?string $string, int $flags = ENT_QUOTES | ENT_HTML5): string
{
if ($string === null) {
return '';
}
return htmlspecialchars($string, $flags, 'UTF-8');
}
/**
* Escape string for HTML attribute context
*
* Use for values inside HTML attributes.
* Example: <input value="<?= OutputHelper::attr($userInput) ?>">
*
* @param string|null $string The string to escape
* @return string Escaped string
*/
public static function attr(?string $string): string
{
if ($string === null) {
return '';
}
// More aggressive escaping for attribute context
return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5 | ENT_SUBSTITUTE, 'UTF-8');
}
/**
* Encode data as JSON for JavaScript context
*
* Use when embedding data in JavaScript.
* Example: <script>const data = <?= OutputHelper::json($data) ?>;</script>
*
* @param mixed $data The data to encode
* @param int $flags json_encode flags
* @return string JSON encoded string (safe for script context)
*/
public static function json($data, int $flags = 0): string
{
// Use HEX encoding for safety in HTML context
$safeFlags = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | $flags;
return json_encode($data, $safeFlags);
}
/**
* URL encode a string
*
* Use for values in URL query strings.
* Example: <a href="/search?q=<?= OutputHelper::url($query) ?>">
*
* @param string|null $string The string to encode
* @return string URL encoded string
*/
public static function url(?string $string): string
{
if ($string === null) {
return '';
}
return rawurlencode($string);
}
/**
* Escape for CSS context
*
* Use for values in inline CSS.
* Example: <div style="color: <?= OutputHelper::css($color) ?>;">
*
* @param string|null $string The string to escape
* @return string Escaped string (only allows safe characters)
*/
public static function css(?string $string): string
{
if ($string === null) {
return '';
}
// Only allow alphanumeric, hyphens, underscores, spaces, and common CSS values
if (!preg_match('/^[a-zA-Z0-9_\-\s#.,()%]+$/', $string)) {
return '';
}
return $string;
}
/**
* Format a number safely
*
* Ensures output is always a valid number.
*
* @param mixed $number The number to format
* @param int $decimals Number of decimal places
* @return string Formatted number
*/
public static function number($number, int $decimals = 0): string
{
return number_format((float)$number, $decimals, '.', ',');
}
/**
* Format an integer safely
*
* @param mixed $value The value to format
* @return int Integer value
*/
public static function int($value): int
{
return (int)$value;
}
/**
* Truncate string with ellipsis
*
* @param string|null $string The string to truncate
* @param int $length Maximum length
* @param string $suffix Suffix to add if truncated
* @return string Truncated and escaped string
*/
public static function truncate(?string $string, int $length = 100, string $suffix = '...'): string
{
if ($string === null) {
return '';
}
if (mb_strlen($string, 'UTF-8') <= $length) {
return self::h($string);
}
return self::h(mb_substr($string, 0, $length, 'UTF-8')) . self::h($suffix);
}
/**
* Format a date safely
*
* @param string|int|null $date Date string, timestamp, or null
* @param string $format PHP date format
* @return string Formatted date
*/
public static function date($date, string $format = 'Y-m-d H:i:s'): string
{
if ($date === null || $date === '') {
return '';
}
if (is_numeric($date)) {
return date($format, (int)$date);
}
$timestamp = strtotime($date);
if ($timestamp === false) {
return '';
}
return date($format, $timestamp);
}
/**
* Check if a string is safe for use as a CSS class name
*
* @param string $class The class name to validate
* @return bool True if safe
*/
public static function isValidCssClass(string $class): bool
{
return preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $class) === 1;
}
/**
* Sanitize CSS class name(s)
*
* @param string|null $classes Space-separated class names
* @return string Sanitized class names
*/
public static function cssClass(?string $classes): string
{
if ($classes === null || $classes === '') {
return '';
}
$classList = explode(' ', $classes);
$validClasses = array_filter($classList, [self::class, 'isValidCssClass']);
return implode(' ', $validClasses);
}
}
/**
* Shorthand function for HTML escaping
*
* @param string|null $string The string to escape
* @return string Escaped string
*/
function h(?string $string): string
{
return OutputHelper::h($string);
}
+128
View File
@@ -0,0 +1,128 @@
<?php
/**
* ResponseHelper - Standardized JSON response formatting
*
* Provides consistent API response structure across all endpoints.
*/
class ResponseHelper
{
/**
* Send a success response
*
* @param array $data Additional data to include
* @param string $message Success message
* @param int $code HTTP status code
*/
public static function success($data = [], $message = 'Success', $code = 200)
{
http_response_code($code);
header('Content-Type: application/json');
echo json_encode(array_merge([
'success' => true,
'message' => $message
], $data));
exit;
}
/**
* Send an error response
*
* @param string $message Error message
* @param int $code HTTP status code
* @param array $data Additional data to include
*/
public static function error($message, $code = 400, $data = [])
{
http_response_code($code);
header('Content-Type: application/json');
echo json_encode(array_merge([
'success' => false,
'error' => $message
], $data));
exit;
}
/**
* Send an unauthorized response (401)
*
* @param string $message Error message
*/
public static function unauthorized($message = 'Authentication required')
{
self::error($message, 401);
}
/**
* Send a forbidden response (403)
*
* @param string $message Error message
*/
public static function forbidden($message = 'Access denied')
{
self::error($message, 403);
}
/**
* Send a not found response (404)
*
* @param string $message Error message
*/
public static function notFound($message = 'Resource not found')
{
self::error($message, 404);
}
/**
* Send a validation error response (422)
*
* @param array $errors Validation errors
* @param string $message Error message
*/
public static function validationError($errors, $message = 'Validation failed')
{
self::error($message, 422, ['validation_errors' => $errors]);
}
/**
* Send a server error response (500)
*
* @param string $message Error message
*/
public static function serverError($message = 'Internal server error')
{
self::error($message, 500);
}
/**
* Send a rate limit exceeded response (429)
*
* @param int $retryAfter Seconds until retry is allowed
* @param string $message Error message
*/
public static function rateLimitExceeded($retryAfter = 60, $message = 'Rate limit exceeded')
{
header('Retry-After: ' . $retryAfter);
self::error($message, 429, ['retry_after' => $retryAfter]);
}
/**
* Send a created response (201)
*
* @param array $data Resource data
* @param string $message Success message
*/
public static function created($data = [], $message = 'Resource created')
{
self::success($data, $message, 201);
}
/**
* Send a no content response (204)
*/
public static function noContent()
{
http_response_code(204);
exit;
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
/**
* 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.
*
* Required config (.env) keys:
* MATRIX_DOMAIN e.g. matrix.lotusguild.org
* SYNAPSE_ADMIN_URL e.g. http://10.10.10.29:8008 (internal client-API URL)
* SYNAPSE_ADMIN_TOKEN a Synapse admin access token
*/
class SynapseHelper
{
/**
* Resolve a local SSO username to its Matrix user ID.
*
* Uses the Synapse Admin API v2 endpoint:
* GET /_synapse/admin/v2/users/@{username}:{domain}
*
* If the account exists in Synapse the method returns the Matrix ID string.
* If the account does not exist, or if Synapse is unreachable / not configured,
* it returns null silently (notifications are best-effort).
*
* @param string $username Local username (e.g. "jared")
* @return string|null Matrix user ID (e.g. "@jared:matrix.lotusguild.org") or null
*/
public static function resolveUsername(string $username): ?string
{
$baseUrl = $GLOBALS['config']['SYNAPSE_ADMIN_URL'] ?? null;
$token = $GLOBALS['config']['SYNAPSE_ADMIN_TOKEN'] ?? null;
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
if (!$baseUrl || !$token || !$domain) {
return null;
}
// Build the Matrix user ID and percent-encode it once for the URL path.
// rawurlencode($username) here would double-encode any special chars when
// the full $matrixId string is encoded again below.
$matrixId = '@' . $username . ':' . $domain;
$url = rtrim($baseUrl, '/') . '/_synapse/admin/v2/users/' . rawurlencode($matrixId);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $token,
'Accept: application/json',
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
$body = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
error_log("SynapseHelper: cURL error resolving '{$username}': {$curlError}");
return null;
}
if ($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"
}
}
// 404 = user not found in Synapse; other codes = error
if ($httpCode !== 404) {
error_log("SynapseHelper: unexpected HTTP {$httpCode} resolving '{$username}'");
}
return null;
}
/**
* Resolve multiple usernames to Matrix IDs.
* Returns only those that were successfully confirmed in Synapse.
*
* @param string[] $usernames
* @return string[] Matrix user IDs
*/
public static function resolveUsernames(array $usernames): array
{
$ids = [];
foreach ($usernames as $username) {
$id = self::resolveUsername($username);
if ($id !== null) {
$ids[] = $id;
}
}
return $ids;
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
/**
* UrlHelper - URL and domain utilities
*
* Provides secure URL generation with host validation.
*/
class UrlHelper
{
/**
* Get the application base URL with validated host
*
* Uses APP_DOMAIN from config if set, otherwise validates HTTP_HOST
* against ALLOWED_HOSTS whitelist.
*
* @return string Base URL (e.g., "https://example.com")
*/
public static function getBaseUrl(): string
{
$protocol = self::getProtocol();
$host = self::getValidatedHost();
return "{$protocol}://{$host}";
}
/**
* Get the current protocol (http or https)
*
* @return string 'https' or 'http'
*/
public static function getProtocol(): string
{
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
return 'https';
}
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
return 'https';
}
if (!empty($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) {
return 'https';
}
return 'http';
}
/**
* Get validated hostname
*
* Priority:
* 1. APP_DOMAIN from config (if set)
* 2. HTTP_HOST if it passes validation
* 3. First allowed host as fallback
*
* @return string Validated hostname
*/
public static function getValidatedHost(): string
{
$config = $GLOBALS['config'] ?? [];
// Use configured APP_DOMAIN if available
if (!empty($config['APP_DOMAIN'])) {
return $config['APP_DOMAIN'];
}
// Get allowed hosts
$allowedHosts = $config['ALLOWED_HOSTS'] ?? ['localhost'];
// Validate HTTP_HOST against whitelist
$httpHost = $_SERVER['HTTP_HOST'] ?? '';
// Strip port if present for comparison
$hostWithoutPort = preg_replace('/:\d+$/', '', $httpHost);
if (in_array($hostWithoutPort, $allowedHosts, true)) {
return $httpHost;
}
// Log suspicious host header
if (!empty($httpHost) && $httpHost !== 'localhost') {
error_log("UrlHelper: Rejected HTTP_HOST '{$httpHost}' - not in allowed hosts");
}
// Return first allowed host as fallback
return $allowedHosts[0] ?? 'localhost';
}
/**
* Build a full URL for a ticket
*
* @param string $ticketId Ticket ID
* @return string Full ticket URL
*/
public static function ticketUrl(string $ticketId): string
{
return self::getBaseUrl() . '/ticket/' . urlencode($ticketId);
}
/**
* Check if the current request is using HTTPS
*
* @return bool True if HTTPS
*/
public static function isSecure(): bool
{
return self::getProtocol() === 'https';
}
}
+357 -12
View File
@@ -1,9 +1,14 @@
<?php <?php
// Main entry point for the application // Main entry point for the application
require_once 'config/config.php'; require_once 'config/config.php';
require_once 'middleware/SecurityHeadersMiddleware.php';
require_once 'middleware/AuthMiddleware.php'; require_once 'middleware/AuthMiddleware.php';
require_once 'models/AuditLogModel.php'; require_once 'models/AuditLogModel.php';
// Apply security headers early
SecurityHeadersMiddleware::apply();
// Parse the URL - no need to remove base path since we're at document root // Parse the URL - no need to remove base path since we're at document root
$request = $_SERVER['REQUEST_URI']; $request = $_SERVER['REQUEST_URI'];
@@ -32,6 +37,31 @@ if (!str_starts_with($requestPath, '/api/')) {
// Initialize audit log model // Initialize audit log model
$GLOBALS['auditLog'] = new AuditLogModel($conn); $GLOBALS['auditLog'] = new AuditLogModel($conn);
// Check if user has a timezone preference and apply it
if ($currentUser && isset($currentUser['user_id'])) {
require_once 'models/UserPreferencesModel.php';
$prefsModel = new UserPreferencesModel($conn);
$userTimezone = $prefsModel->getPreference($currentUser['user_id'], 'timezone', null);
if ($userTimezone && in_array($userTimezone, DateTimeZone::listIdentifiers())) {
// Override system timezone with user preference (validated against known identifiers)
date_default_timezone_set($userTimezone);
$GLOBALS['config']['TIMEZONE'] = $userTimezone;
$now = new DateTime('now', new DateTimeZone($userTimezone));
$GLOBALS['config']['TIMEZONE_OFFSET'] = $now->getOffset() / 60;
$GLOBALS['config']['TIMEZONE_ABBREV'] = $now->format('T');
}
}
}
// Helper: require admin or render styled 403 and exit
function requireAdmin(?array $user): void
{
if (!$user || empty($user['is_admin'])) {
http_response_code(403);
include __DIR__ . '/views/error_403.php';
exit;
}
} }
// Simple router // Simple router
@@ -41,41 +71,357 @@ switch (true) {
$controller = new DashboardController($conn); $controller = new DashboardController($conn);
$controller->index(); $controller->index();
break; break;
case preg_match('/^\/ticket\/(\d+)$/', $requestPath, $matches): case preg_match('/^\/ticket\/(\d+)$/', $requestPath, $matches):
require_once 'controllers/TicketController.php'; require_once 'controllers/TicketController.php';
$controller = new TicketController($conn); $controller = new TicketController($conn);
$controller->view($matches[1]); $controller->view($matches[1]);
break; break;
case $requestPath == '/ticket/create': case $requestPath == '/ticket/create':
require_once 'controllers/TicketController.php'; require_once 'controllers/TicketController.php';
$controller = new TicketController($conn); $controller = new TicketController($conn);
$controller->create(); $controller->create();
break; break;
// API Routes - these handle their own database connections // API Routes - these handle their own database connections
case $requestPath == '/api/update_ticket.php': case $requestPath == '/api/update_ticket.php':
require_once 'api/update_ticket.php'; require_once 'api/update_ticket.php';
break; break;
case $requestPath == '/api/add_comment.php': case $requestPath == '/api/add_comment.php':
require_once 'api/add_comment.php'; require_once 'api/add_comment.php';
break; break;
case $requestPath == '/api/update_comment.php':
require_once 'api/update_comment.php';
break;
case $requestPath == '/api/delete_comment.php':
require_once 'api/delete_comment.php';
break;
case $requestPath == '/api/ticket_dependencies.php':
require_once 'api/ticket_dependencies.php';
break;
case $requestPath == '/api/upload_attachment.php':
require_once 'api/upload_attachment.php';
break;
case $requestPath == '/api/delete_attachment.php':
require_once 'api/delete_attachment.php';
break;
case $requestPath == '/api/get_users.php':
require_once 'api/get_users.php';
break;
case $requestPath == '/api/get_comments.php':
require_once 'api/get_comments.php';
break;
case $requestPath == '/api/watch_ticket.php':
require_once 'api/watch_ticket.php';
break;
case $requestPath == '/api/assign_ticket.php':
require_once 'api/assign_ticket.php';
break;
case $requestPath == '/api/get_template.php':
require_once 'api/get_template.php';
break;
case $requestPath == '/api/bulk_operation.php':
require_once 'api/bulk_operation.php';
break;
case $requestPath == '/api/export_tickets.php':
require_once 'api/export_tickets.php';
break;
case $requestPath == '/api/generate_api_key.php':
require_once 'api/generate_api_key.php';
break;
case $requestPath == '/api/revoke_api_key.php':
require_once 'api/revoke_api_key.php';
break;
case $requestPath == '/api/manage_templates.php':
require_once 'api/manage_templates.php';
break;
case $requestPath == '/api/manage_workflows.php':
require_once 'api/manage_workflows.php';
break;
case $requestPath == '/api/manage_recurring.php':
require_once 'api/manage_recurring.php';
break;
case $requestPath == '/api/check_duplicates.php':
require_once 'api/check_duplicates.php';
break;
case $requestPath == '/api/custom_fields.php':
require_once 'api/custom_fields.php';
break;
case $requestPath == '/api/saved_filters.php':
require_once 'api/saved_filters.php';
break;
case $requestPath == '/api/audit_log.php':
require_once 'api/audit_log.php';
break;
case $requestPath == '/api/user_preferences.php':
require_once 'api/user_preferences.php';
break;
case $requestPath == '/api/download_attachment.php':
require_once 'api/download_attachment.php';
break;
case $requestPath == '/api/clone_ticket.php':
require_once 'api/clone_ticket.php';
break;
case $requestPath == '/api/health.php':
require_once 'api/health.php';
break;
case $requestPath == '/api/notifications.php':
require_once 'api/notifications.php';
break;
case $requestPath == '/api/user_avatar.php':
require_once 'api/user_avatar.php';
break;
// Admin Routes - require admin privileges
case $requestPath == '/admin/recurring-tickets':
requireAdmin($currentUser);
require_once 'models/RecurringTicketModel.php';
$recurringModel = new RecurringTicketModel($conn);
$recurringTickets = $recurringModel->getAll(true);
include 'views/admin/RecurringTicketsView.php';
break;
case $requestPath == '/admin/custom-fields':
requireAdmin($currentUser);
require_once 'models/CustomFieldModel.php';
$fieldModel = new CustomFieldModel($conn);
$customFields = $fieldModel->getAllDefinitions(null, false);
include 'views/admin/CustomFieldsView.php';
break;
case $requestPath == '/admin/workflow':
requireAdmin($currentUser);
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
$workflows = [];
while ($row = $result->fetch_assoc()) {
$workflows[] = $row;
}
include 'views/admin/WorkflowDesignerView.php';
break;
case $requestPath == '/admin/templates':
requireAdmin($currentUser);
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
$templates = [];
while ($row = $result->fetch_assoc()) {
$templates[] = $row;
}
include 'views/admin/TemplatesView.php';
break;
case $requestPath == '/admin/audit-log':
requireAdmin($currentUser);
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
$perPage = 50;
$offset = ($page - 1) * $perPage;
$filters = [];
$whereConditions = [];
$params = [];
$types = '';
$allowedActionTypes = ['create','update','delete','comment','assign','status_change','login','security',
'ticket_create','ticket_update','ticket_delete','attachment_delete','attachment_upload'];
if (!empty($_GET['action_type']) && in_array($_GET['action_type'], $allowedActionTypes, true)) {
$whereConditions[] = "al.action_type = ?";
$params[] = $_GET['action_type'];
$types .= 's';
$filters['action_type'] = $_GET['action_type'];
}
if (!empty($_GET['user_id'])) {
$whereConditions[] = "al.user_id = ?";
$params[] = (int)$_GET['user_id'];
$types .= 'i';
$filters['user_id'] = (int)$_GET['user_id'];
}
if (!empty($_GET['date_from']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['date_from'])) {
$whereConditions[] = "DATE(al.created_at) >= ?";
$params[] = $_GET['date_from'];
$types .= 's';
$filters['date_from'] = $_GET['date_from'];
}
if (!empty($_GET['date_to']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['date_to'])) {
$whereConditions[] = "DATE(al.created_at) <= ?";
$params[] = $_GET['date_to'];
$types .= 's';
$filters['date_to'] = $_GET['date_to'];
}
$where = !empty($whereConditions) ? 'WHERE ' . implode(' AND ', $whereConditions) : '';
// $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);
$stmt->execute();
$countResult = $stmt->get_result();
} else {
$countResult = $conn->query($countSql);
}
$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 . "
ORDER BY al.created_at DESC
LIMIT " . (int)$perPage . " OFFSET " . (int)$offset;
if (!empty($params)) {
$stmt = $conn->prepare($sql);
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
} else {
$result = $conn->query($sql);
}
$auditLogs = [];
while ($row = $result->fetch_assoc()) {
$auditLogs[] = $row;
}
$usersResult = $conn->query("SELECT user_id, username, display_name FROM users ORDER BY display_name");
$users = [];
while ($row = $usersResult->fetch_assoc()) {
$users[] = $row;
}
include 'views/admin/AuditLogView.php';
break;
case $requestPath == '/admin/api-keys':
requireAdmin($currentUser);
require_once 'models/ApiKeyModel.php';
$apiKeyModel = new ApiKeyModel($conn);
$apiKeys = $apiKeyModel->getAllKeys();
include 'views/admin/ApiKeysView.php';
break;
case $requestPath == '/admin/user-activity':
requireAdmin($currentUser);
$dateRange = [
'from' => $_GET['date_from'] ?? date('Y-m-d', strtotime('-30 days')),
'to' => $_GET['date_to'] ?? date('Y-m-d')
];
// Optimized query using LEFT JOINs with aggregated subqueries instead of correlated subqueries
// This eliminates N+1 query pattern and runs much faster with many users
$sql = "SELECT
u.user_id, u.username, u.display_name, u.is_admin,
COALESCE(tc.tickets_created, 0) as tickets_created,
COALESCE(tr.tickets_resolved, 0) as tickets_resolved,
COALESCE(cm.comments_added, 0) as comments_added,
COALESCE(ta.tickets_assigned, 0) as tickets_assigned,
al.last_activity
FROM users u
LEFT JOIN (
SELECT created_by, COUNT(*) as tickets_created
FROM tickets
WHERE DATE(created_at) BETWEEN ? AND ?
GROUP BY created_by
) tc ON u.user_id = tc.created_by
LEFT JOIN (
SELECT assigned_to, COUNT(*) as tickets_resolved
FROM tickets
WHERE status = 'Closed' AND DATE(updated_at) BETWEEN ? AND ?
GROUP BY assigned_to
) tr ON u.user_id = tr.assigned_to
LEFT JOIN (
SELECT user_id, COUNT(*) as comments_added
FROM ticket_comments
WHERE DATE(created_at) BETWEEN ? AND ?
GROUP BY user_id
) cm ON u.user_id = cm.user_id
LEFT JOIN (
SELECT assigned_to, COUNT(*) as tickets_assigned
FROM tickets
WHERE DATE(created_at) BETWEEN ? AND ?
GROUP BY assigned_to
) ta ON u.user_id = ta.assigned_to
LEFT JOIN (
SELECT user_id, MAX(created_at) as last_activity
FROM audit_log
GROUP BY user_id
) al ON u.user_id = al.user_id
ORDER BY tickets_created DESC, tickets_resolved DESC";
$stmt = $conn->prepare($sql);
$stmt->bind_param(
'ssssssss',
$dateRange['from'],
$dateRange['to'],
$dateRange['from'],
$dateRange['to'],
$dateRange['from'],
$dateRange['to'],
$dateRange['from'],
$dateRange['to']
);
$stmt->execute();
$result = $stmt->get_result();
$userStats = [];
while ($row = $result->fetch_assoc()) {
$userStats[] = $row;
}
$stmt->close();
include 'views/admin/UserActivityView.php';
break;
// Legacy support for old URLs // Legacy support for old URLs
case $requestPath == '/dashboard.php': case $requestPath == '/dashboard.php':
header("Location: /"); header("Location: /");
exit; exit;
case preg_match('/^\/ticket\.php/', $requestPath) && isset($_GET['id']): case preg_match('/^\/ticket\.php/', $requestPath) && isset($_GET['id']):
header("Location: /ticket/" . $_GET['id']); $legacyId = (string)$_GET['id'];
if (ctype_digit($legacyId) && (int)$legacyId > 0) {
header("Location: /ticket/" . $legacyId);
} else {
header("Location: /");
}
exit; exit;
default: default:
// 404 Not Found http_response_code(404);
header("HTTP/1.0 404 Not Found"); include __DIR__ . '/views/error_404.php';
echo '404 Page Not Found';
break; break;
} }
@@ -83,4 +429,3 @@ switch (true) {
if (isset($conn)) { if (isset($conn)) {
$conn->close(); $conn->close();
} }
?>
+14 -6
View File
@@ -1,16 +1,20 @@
<?php <?php
/** /**
* ApiKeyAuth - Handles API key authentication for external services * ApiKeyAuth - Handles API key authentication for external services
*/ */
require_once dirname(__DIR__) . '/models/ApiKeyModel.php'; require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php'; require_once dirname(__DIR__) . '/models/UserModel.php';
class ApiKeyAuth { class ApiKeyAuth
{
private $apiKeyModel; private $apiKeyModel;
private $userModel; private $userModel;
private $conn; private $conn;
public function __construct($conn) { public function __construct($conn)
{
$this->conn = $conn; $this->conn = $conn;
$this->apiKeyModel = new ApiKeyModel($conn); $this->apiKeyModel = new ApiKeyModel($conn);
$this->userModel = new UserModel($conn); $this->userModel = new UserModel($conn);
@@ -22,7 +26,8 @@ class ApiKeyAuth {
* @return array User data for system user * @return array User data for system user
* @throws Exception if authentication fails * @throws Exception if authentication fails
*/ */
public function authenticate() { public function authenticate()
{
// Get Authorization header // Get Authorization header
$authHeader = $this->getAuthorizationHeader(); $authHeader = $this->getAuthorizationHeader();
@@ -67,7 +72,8 @@ class ApiKeyAuth {
* *
* @return string|null Authorization header value * @return string|null Authorization header value
*/ */
private function getAuthorizationHeader() { private function getAuthorizationHeader()
{
// Try different header formats // Try different header formats
if (isset($_SERVER['HTTP_AUTHORIZATION'])) { if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
return $_SERVER['HTTP_AUTHORIZATION']; return $_SERVER['HTTP_AUTHORIZATION'];
@@ -96,7 +102,8 @@ class ApiKeyAuth {
* *
* @param string $message Error message * @param string $message Error message
*/ */
private function sendUnauthorized($message) { private function sendUnauthorized($message)
{
header('HTTP/1.1 401 Unauthorized'); header('HTTP/1.1 401 Unauthorized');
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode([ echo json_encode([
@@ -111,7 +118,8 @@ class ApiKeyAuth {
* *
* @return array|null User data or null if not authenticated * @return array|null User data or null if not authenticated
*/ */
public function verifyOptional() { public function verifyOptional()
{
$authHeader = $this->getAuthorizationHeader(); $authHeader = $this->getAuthorizationHeader();
if (empty($authHeader)) { if (empty($authHeader)) {
+88 -13
View File
@@ -1,40 +1,90 @@
<?php <?php
/** /**
* AuthMiddleware - Handles authentication via Authelia forward auth headers * AuthMiddleware - Handles authentication via Authelia forward auth headers
*/ */
require_once dirname(__DIR__) . '/models/UserModel.php'; require_once dirname(__DIR__) . '/models/UserModel.php';
class AuthMiddleware { class AuthMiddleware
{
private $userModel; private $userModel;
private $conn; private $conn;
public function __construct($conn) { public function __construct($conn)
{
$this->conn = $conn; $this->conn = $conn;
$this->userModel = new UserModel($conn); $this->userModel = new UserModel($conn);
} }
/**
* Log security event for authentication failures
*
* @param string $event Event type (e.g., 'auth_required', 'access_denied', 'session_expired')
* @param array $context Additional context data
*/
private function logSecurityEvent(string $event, array $context = []): void
{
$logData = [
'event' => $event,
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'forwarded_for' => $_SERVER['HTTP_X_FORWARDED_FOR'] ?? null,
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
'request_uri' => $_SERVER['REQUEST_URI'] ?? 'unknown',
'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
'timestamp' => date('c')
];
// Merge additional context
$logData = array_merge($logData, $context);
// Remove null values for cleaner logs
$logData = array_filter($logData, fn($v) => $v !== null);
// Format log message
$message = sprintf(
"[SECURITY] %s: %s",
strtoupper($event),
json_encode($logData, JSON_UNESCAPED_SLASHES)
);
error_log($message);
}
/** /**
* Authenticate user from Authelia forward auth headers * Authenticate user from Authelia forward auth headers
* *
* @return array User data array * @return array User data array
* @throws Exception if authentication fails * @throws Exception if authentication fails
*/ */
public function authenticate() { public function authenticate()
{
// Start session if not already started with secure settings // Start session if not already started with secure settings
if (session_status() === PHP_SESSION_NONE) { if (session_status() === PHP_SESSION_NONE) {
// Configure secure session settings // Configure secure session settings
ini_set('session.cookie_httponly', 1); ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1); // Requires HTTPS ini_set('session.cookie_secure', 1); // Requires HTTPS
ini_set('session.cookie_samesite', 'Strict'); ini_set('session.cookie_samesite', 'Lax'); // Lax allows redirects from Authelia
ini_set('session.use_strict_mode', 1); ini_set('session.use_strict_mode', 1);
$sessionTimeout = $GLOBALS['config']['SESSION_TIMEOUT'] ?? 18000;
ini_set('session.gc_maxlifetime', $sessionTimeout);
ini_set('session.cookie_lifetime', 0); // Until browser closes
session_start(); session_start();
} }
// Check if user is already authenticated in session // Check if user is already authenticated in session
if (isset($_SESSION['user']) && isset($_SESSION['user']['user_id'])) { if (isset($_SESSION['user']) && isset($_SESSION['user']['user_id'])) {
// Verify session hasn't expired (5 hour timeout) // Verify session hasn't expired
if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > 18000)) { $sessionTimeout = $GLOBALS['config']['SESSION_TIMEOUT'] ?? 18000;
if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > $sessionTimeout)) {
// Log session expiration
$this->logSecurityEvent('session_expired', [
'username' => $_SESSION['user']['username'] ?? 'unknown',
'user_id' => $_SESSION['user']['user_id'] ?? null,
'session_age_seconds' => time() - $_SESSION['last_activity']
]);
// Session expired, clear it // Session expired, clear it
session_unset(); session_unset();
session_destroy(); session_destroy();
@@ -92,7 +142,8 @@ class AuthMiddleware {
* @param string $header Header name * @param string $header Header name
* @return string|null Header value or null if not set * @return string|null Header value or null if not set
*/ */
private function getHeader($header) { private function getHeader($header)
{
if (isset($_SERVER[$header])) { if (isset($_SERVER[$header])) {
return $_SERVER[$header]; return $_SERVER[$header];
} }
@@ -105,13 +156,20 @@ class AuthMiddleware {
* @param string $groups Comma-separated group names * @param string $groups Comma-separated group names
* @return bool True if user has access * @return bool True if user has access
*/ */
private function checkGroupAccess($groups) { private function checkGroupAccess($groups)
{
if (empty($groups)) { if (empty($groups)) {
return false; return false;
} }
// Check for admin or employee group membership // Check for admin or employee group membership
$userGroups = array_map('trim', explode(',', strtolower($groups))); // Filter to safe characters only to prevent header injection attacks
$userGroups = array_filter(
array_map('trim', explode(',', strtolower($groups))),
function ($g) {
return preg_match('/^[a-z0-9_\-]+$/', $g);
}
);
$requiredGroups = ['admin', 'employee']; $requiredGroups = ['admin', 'employee'];
return !empty(array_intersect($userGroups, $requiredGroups)); return !empty(array_intersect($userGroups, $requiredGroups));
@@ -120,7 +178,13 @@ class AuthMiddleware {
/** /**
* Redirect to Authelia login * Redirect to Authelia login
*/ */
private function redirectToAuth() { private function redirectToAuth()
{
// Log unauthenticated access attempt
$this->logSecurityEvent('auth_required', [
'reason' => 'no_auth_headers'
]);
// Redirect to the auth endpoint (Authelia will handle the redirect back) // Redirect to the auth endpoint (Authelia will handle the redirect back)
header('HTTP/1.1 401 Unauthorized'); header('HTTP/1.1 401 Unauthorized');
echo '<!DOCTYPE html> echo '<!DOCTYPE html>
@@ -184,7 +248,16 @@ class AuthMiddleware {
* @param string $username Username * @param string $username Username
* @param string $groups User groups * @param string $groups User groups
*/ */
private function showAccessDenied($username, $groups) { private function showAccessDenied($username, $groups)
{
// Log access denied event with user details
$this->logSecurityEvent('access_denied', [
'username' => $username,
'groups' => $groups ?: 'none',
'required_groups' => 'admin,employee',
'reason' => 'insufficient_group_membership'
]);
header('HTTP/1.1 403 Forbidden'); header('HTTP/1.1 403 Forbidden');
echo '<!DOCTYPE html> echo '<!DOCTYPE html>
<html> <html>
@@ -247,7 +320,8 @@ class AuthMiddleware {
* *
* @return array|null User data or null if not authenticated * @return array|null User data or null if not authenticated
*/ */
public static function getCurrentUser() { public static function getCurrentUser()
{
if (session_status() === PHP_SESSION_NONE) { if (session_status() === PHP_SESSION_NONE) {
session_start(); session_start();
} }
@@ -258,7 +332,8 @@ class AuthMiddleware {
/** /**
* Logout current user * Logout current user
*/ */
public static function logout() { public static function logout()
{
if (session_status() === PHP_SESSION_NONE) { if (session_status() === PHP_SESSION_NONE) {
session_start(); session_start();
} }
+27 -9
View File
@@ -1,17 +1,20 @@
<?php <?php
/** /**
* CSRF Protection Middleware * CSRF Protection Middleware
* Generates and validates CSRF tokens for all state-changing operations * Generates and validates CSRF tokens for all state-changing operations
*/ */
class CsrfMiddleware { class CsrfMiddleware
private static $tokenName = 'csrf_token'; {
private static $tokenTime = 'csrf_token_time'; private static string $tokenName = 'csrf_token';
private static $tokenLifetime = 3600; // 1 hour private static string $tokenTime = 'csrf_token_time';
private static int $tokenLifetime = 3600; // 1 hour
/** /**
* Generate a new CSRF token * Generate a new CSRF token
*/ */
public static function generateToken() { public static function generateToken(): string
{
$_SESSION[self::$tokenName] = bin2hex(random_bytes(32)); $_SESSION[self::$tokenName] = bin2hex(random_bytes(32));
$_SESSION[self::$tokenTime] = time(); $_SESSION[self::$tokenTime] = time();
return $_SESSION[self::$tokenName]; return $_SESSION[self::$tokenName];
@@ -20,7 +23,8 @@ class CsrfMiddleware {
/** /**
* Get current CSRF token, regenerate if expired * Get current CSRF token, regenerate if expired
*/ */
public static function getToken() { public static function getToken(): string
{
if (!isset($_SESSION[self::$tokenName]) || self::isTokenExpired()) { if (!isset($_SESSION[self::$tokenName]) || self::isTokenExpired()) {
return self::generateToken(); return self::generateToken();
} }
@@ -30,7 +34,8 @@ class CsrfMiddleware {
/** /**
* Validate CSRF token (constant-time comparison) * Validate CSRF token (constant-time comparison)
*/ */
public static function validateToken($token) { public static function validateToken(string $token): bool
{
if (!isset($_SESSION[self::$tokenName])) { if (!isset($_SESSION[self::$tokenName])) {
return false; return false;
} }
@@ -44,12 +49,25 @@ class CsrfMiddleware {
return hash_equals($_SESSION[self::$tokenName], $token); return hash_equals($_SESSION[self::$tokenName], $token);
} }
/**
* Rotate the CSRF token after a successful validated POST.
* Call this after validateToken() returns true, then include
* the new token in the JSON response as 'csrf_token' so the
* client can update window.CSRF_TOKEN for subsequent requests.
*
* @return string The new token
*/
public static function rotateToken(): string
{
return self::generateToken();
}
/** /**
* Check if token is expired * Check if token is expired
*/ */
private static function isTokenExpired() { private static function isTokenExpired(): bool
{
return !isset($_SESSION[self::$tokenTime]) || return !isset($_SESSION[self::$tokenTime]) ||
(time() - $_SESSION[self::$tokenTime]) > self::$tokenLifetime; (time() - $_SESSION[self::$tokenTime]) > self::$tokenLifetime;
} }
} }
?>
+299
View File
@@ -0,0 +1,299 @@
<?php
/**
* Rate Limiting Middleware
*
* Implements both session-based and IP-based rate limiting to prevent abuse.
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
*/
class RateLimitMiddleware
{
// Default limits
public const DEFAULT_LIMIT = 100; // requests per window (session)
public const API_LIMIT = 60; // API requests per window (session)
public const IP_LIMIT = 300; // IP-based requests per window (more generous)
public const IP_API_LIMIT = 120; // IP-based API requests per window
public const WINDOW_SECONDS = 60; // 1 minute window
// Directory for IP rate limit storage
private static ?string $rateLimitDir = null;
/**
* Get the rate limit storage directory
*
* @return string Path to rate limit storage directory
*/
private static function getRateLimitDir(): string
{
if (self::$rateLimitDir === null) {
self::$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
if (!is_dir(self::$rateLimitDir)) {
mkdir(self::$rateLimitDir, 0755, true);
}
}
return self::$rateLimitDir;
}
/**
* Get the client's IP address
*
* @return string Client IP address
*/
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;
}
}
}
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
}
/**
* Check IP-based rate limit
*
* @param string $type 'default' or 'api'
* @return bool True if request is allowed, false if rate limited
*/
private static function checkIpRateLimit(string $type = 'default'): bool
{
$ip = self::getClientIp();
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
$now = time();
// Create a hash of the IP for the filename (security + filesystem safety)
$ipHash = hash('sha256', $ip . '_' . $type);
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
// Load existing rate data
$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;
}
}
}
// Check if window has expired
if ($now - $rateData['window_start'] >= 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);
// Check if over limit
return $rateData['count'] <= $limit;
}
/**
* Clean up old rate limit files (call periodically)
*
* Uses DirectoryIterator instead of glob() for better memory efficiency.
* A dedicated cron script (cron/cleanup_ratelimit.php) should also run for reliable cleanup.
*/
public static function cleanupOldFiles(): void
{
$dir = self::getRateLimitDir();
$lockFile = $dir . '/.cleanup.lock';
$now = time();
$maxAge = self::WINDOW_SECONDS * 2; // Files older than 2 windows
$maxLockAge = 60; // Release stale locks after 60 seconds
// Check for existing lock to prevent concurrent cleanups
if (file_exists($lockFile)) {
$lockAge = $now - filemtime($lockFile);
if ($lockAge < $maxLockAge) {
return; // Cleanup already in progress
}
@unlink($lockFile); // Stale lock
}
// Try to acquire lock
if (!@touch($lockFile)) {
return;
}
try {
$iterator = new DirectoryIterator($dir);
$deleted = 0;
$maxDeletes = 50; // Limit deletions per request to avoid blocking
foreach ($iterator as $file) {
if ($deleted >= $maxDeletes) {
break; // Let cron handle the rest
}
if ($file->isDot() || !$file->isFile()) {
continue;
}
$filename = $file->getFilename();
if ($filename === '.cleanup.lock' || !str_ends_with($filename, '.json')) {
continue;
}
if ($now - $file->getMTime() > $maxAge) {
if (@unlink($file->getPathname())) {
$deleted++;
}
}
}
} finally {
@unlink($lockFile);
}
}
/**
* Check rate limit for current request (both session and IP)
*
* @param string $type 'default' or 'api'
* @return bool True if request is allowed, false if rate limited
*/
public static function check(string $type = 'default'): bool
{
// First check IP-based rate limit (prevents session bypass)
if (!self::checkIpRateLimit($type)) {
return false;
}
// Then check session-based rate limit
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
$key = 'rate_limit_' . $type;
$now = time();
// Initialize rate limit tracking
if (!isset($_SESSION[$key])) {
$_SESSION[$key] = [
'count' => 0,
'window_start' => $now
];
}
$rateData = &$_SESSION[$key];
// Check if window has expired
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
// Reset for new window
$rateData['count'] = 0;
$rateData['window_start'] = $now;
}
// Increment request count
$rateData['count']++;
// Check if over limit
if ($rateData['count'] > $limit) {
return false;
}
return true;
}
/**
* Apply rate limiting and send error response if exceeded
*
* @param string $type 'default' or 'api'
* @param bool $addHeaders Whether to add rate limit headers to response
*/
public static function apply(string $type = 'default', bool $addHeaders = true): void
{
// Periodically clean up old rate limit files (2% chance per request)
// Note: For production, use cron/cleanup_ratelimit.php for reliable cleanup
if (mt_rand(1, 50) === 1) {
self::cleanupOldFiles();
}
if (!self::check($type)) {
http_response_code(429);
header('Content-Type: application/json');
header('Retry-After: ' . self::WINDOW_SECONDS);
if ($addHeaders) {
self::addHeaders($type);
}
echo json_encode([
'success' => false,
'error' => 'Rate limit exceeded. Please try again later.',
'retry_after' => self::WINDOW_SECONDS
]);
exit;
}
// Add rate limit headers to successful responses
if ($addHeaders) {
self::addHeaders($type);
}
}
/**
* Get current rate limit status
*
* @param string $type 'default' or 'api'
* @return array Rate limit status
*/
public static function getStatus(string $type = 'default'): array
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
$key = 'rate_limit_' . $type;
$now = time();
if (!isset($_SESSION[$key])) {
return [
'limit' => $limit,
'remaining' => $limit,
'reset' => $now + self::WINDOW_SECONDS
];
}
$rateData = $_SESSION[$key];
// Check if window has expired
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
return [
'limit' => $limit,
'remaining' => $limit,
'reset' => $now + self::WINDOW_SECONDS
];
}
return [
'limit' => $limit,
'remaining' => max(0, $limit - $rateData['count']),
'reset' => $rateData['window_start'] + self::WINDOW_SECONDS
];
}
/**
* Add rate limit headers to response
*
* @param string $type 'default' or 'api'
*/
public static function addHeaders(string $type = 'default'): void
{
$status = self::getStatus($type);
header('X-RateLimit-Limit: ' . $status['limit']);
header('X-RateLimit-Remaining: ' . $status['remaining']);
header('X-RateLimit-Reset: ' . $status['reset']);
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
/**
* Security Headers Middleware
*
* Applies security-related HTTP headers to all responses.
*/
class SecurityHeadersMiddleware
{
private static ?string $nonce = null;
/**
* Generate or retrieve the CSP nonce for this request
*
* @return string The nonce value
*/
public static function getNonce(): string
{
if (self::$nonce === null) {
self::$nonce = base64_encode(random_bytes(16));
}
return self::$nonce;
}
/**
* Apply security headers to the response
*/
public static function apply(): void
{
$nonce = self::getNonce();
// Content Security Policy - restricts where resources can be loaded from
// Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces
// All inline event handlers have been refactored to use addEventListener with data-action attributes
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net;");
// Prevent clickjacking by disallowing framing
header("X-Frame-Options: DENY");
// Prevent MIME type sniffing
header("X-Content-Type-Options: nosniff");
// Enable XSS filtering in older browsers
header("X-XSS-Protection: 1; mode=block");
// Control referrer information sent with requests
header("Referrer-Policy: strict-origin-when-cross-origin");
// Permissions Policy - disable unnecessary browser features
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
}
}
-17
View File
@@ -1,17 +0,0 @@
-- Create users table for SSO integration
CREATE TABLE IF NOT EXISTS users (
user_id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
display_name VARCHAR(255),
email VARCHAR(255),
groups TEXT,
is_admin BOOLEAN DEFAULT FALSE,
last_login TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Insert system user for hwmonDaemon
INSERT INTO users (username, display_name, email, groups, is_admin, created_at)
VALUES ('system', 'System', 'system@lotusguild.org', '', FALSE, NOW())
ON DUPLICATE KEY UPDATE username = username;
-15
View File
@@ -1,15 +0,0 @@
-- Create API keys table for external service authentication
CREATE TABLE IF NOT EXISTS api_keys (
api_key_id INT AUTO_INCREMENT PRIMARY KEY,
key_name VARCHAR(100) NOT NULL,
key_hash VARCHAR(255) UNIQUE NOT NULL,
key_prefix VARCHAR(20) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_by INT,
last_used TIMESTAMP NULL,
expires_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL,
INDEX idx_key_hash (key_hash),
INDEX idx_is_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-16
View File
@@ -1,16 +0,0 @@
-- Create audit log table for tracking all user actions
CREATE TABLE IF NOT EXISTS audit_log (
audit_id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
action_type VARCHAR(50) NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id VARCHAR(50),
details JSON,
ip_address VARCHAR(45),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE SET NULL,
INDEX idx_user_id (user_id),
INDEX idx_created_at (created_at),
INDEX idx_entity (entity_type, entity_id),
INDEX idx_action_type (action_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-30
View File
@@ -1,30 +0,0 @@
-- Add user tracking columns to tickets table
ALTER TABLE tickets
ADD COLUMN IF NOT EXISTS created_by INT,
ADD COLUMN IF NOT EXISTS updated_by INT,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP NULL;
-- Add foreign key constraints if they don't exist
SET @fk_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE CONSTRAINT_NAME = 'fk_tickets_created_by'
AND TABLE_NAME = 'tickets'
AND TABLE_SCHEMA = DATABASE());
SET @sql = IF(@fk_exists = 0,
'ALTER TABLE tickets ADD CONSTRAINT fk_tickets_created_by FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL',
'SELECT "Foreign key fk_tickets_created_by already exists"');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @fk_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE CONSTRAINT_NAME = 'fk_tickets_updated_by'
AND TABLE_NAME = 'tickets'
AND TABLE_SCHEMA = DATABASE());
SET @sql = IF(@fk_exists = 0,
'ALTER TABLE tickets ADD CONSTRAINT fk_tickets_updated_by FOREIGN KEY (updated_by) REFERENCES users(user_id) ON DELETE SET NULL',
'SELECT "Foreign key fk_tickets_updated_by already exists"');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-19
View File
@@ -1,19 +0,0 @@
-- Add user_id column to ticket_comments table
ALTER TABLE ticket_comments
ADD COLUMN IF NOT EXISTS user_id INT;
-- Add foreign key constraint if it doesn't exist
SET @fk_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE CONSTRAINT_NAME = 'fk_comments_user_id'
AND TABLE_NAME = 'ticket_comments'
AND TABLE_SCHEMA = DATABASE());
SET @sql = IF(@fk_exists = 0,
'ALTER TABLE ticket_comments ADD CONSTRAINT fk_comments_user_id FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE SET NULL',
'SELECT "Foreign key fk_comments_user_id already exists"');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Update existing comments to reference jared user (first admin)
-- This will be done after jared user is created via web login
-39
View File
@@ -1,39 +0,0 @@
-- Add database indexes for performance optimization
-- Check and create index on tickets.status
SET @index_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_NAME = 'tickets'
AND INDEX_NAME = 'idx_status'
AND TABLE_SCHEMA = DATABASE());
SET @sql = IF(@index_exists = 0,
'CREATE INDEX idx_status ON tickets(status)',
'SELECT "Index idx_status already exists"');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Check and create index on tickets.priority
SET @index_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_NAME = 'tickets'
AND INDEX_NAME = 'idx_priority'
AND TABLE_SCHEMA = DATABASE());
SET @sql = IF(@index_exists = 0,
'CREATE INDEX idx_priority ON tickets(priority)',
'SELECT "Index idx_priority already exists"');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Check and create index on tickets.created_at
SET @index_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_NAME = 'tickets'
AND INDEX_NAME = 'idx_tickets_created_at'
AND TABLE_SCHEMA = DATABASE());
SET @sql = IF(@index_exists = 0,
'CREATE INDEX idx_tickets_created_at ON tickets(created_at)',
'SELECT "Index idx_tickets_created_at already exists"');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-13
View File
@@ -1,13 +0,0 @@
-- Migration 007: Add ticket assignment functionality
-- Adds assigned_to column to tickets table
-- Add assigned_to column to tickets table
ALTER TABLE tickets
ADD COLUMN assigned_to INT NULL,
ADD CONSTRAINT fk_tickets_assigned_to
FOREIGN KEY (assigned_to)
REFERENCES users(user_id)
ON DELETE SET NULL;
-- Add index for performance
CREATE INDEX idx_assigned_to ON tickets(assigned_to);
-31
View File
@@ -1,31 +0,0 @@
-- Migration 008: Add status workflow management
-- Creates status_transitions table for workflow validation
-- Table to define allowed status transitions
CREATE TABLE status_transitions (
transition_id INT AUTO_INCREMENT PRIMARY KEY,
from_status VARCHAR(50) NOT NULL,
to_status VARCHAR(50) NOT NULL,
requires_comment BOOLEAN DEFAULT FALSE,
requires_admin BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_transition (from_status, to_status),
INDEX idx_from_status (from_status)
);
-- Insert default transitions
INSERT INTO status_transitions (from_status, to_status, requires_comment) VALUES
('Open', 'In Progress', FALSE),
('Open', 'Closed', TRUE),
('In Progress', 'Open', FALSE),
('In Progress', 'Closed', TRUE),
('Closed', 'Open', TRUE),
('Closed', 'In Progress', FALSE);
-- Add new status "Resolved"
INSERT INTO status_transitions (from_status, to_status, requires_comment) VALUES
('In Progress', 'Resolved', FALSE),
('Resolved', 'Closed', FALSE),
('Resolved', 'In Progress', TRUE),
('Open', 'Resolved', FALSE);
-24
View File
@@ -1,24 +0,0 @@
-- Migration 009: Add ticket templates
-- Creates ticket_templates table for reusable ticket templates
CREATE TABLE ticket_templates (
template_id INT AUTO_INCREMENT PRIMARY KEY,
template_name VARCHAR(100) NOT NULL,
title_template VARCHAR(255) NOT NULL,
description_template TEXT NOT NULL,
category VARCHAR(50),
type VARCHAR(50),
default_priority INT DEFAULT 4,
created_by INT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(user_id),
INDEX idx_template_name (template_name)
);
-- Insert default templates
INSERT INTO ticket_templates (template_name, title_template, description_template, category, type, default_priority) VALUES
('Hardware Failure', 'Hardware Failure: [Device Name]', 'Device: \nIssue: \nError Messages: \nTroubleshooting Done: ', 'Hardware', 'Problem', 2),
('Software Installation', 'Install [Software Name]', 'Software: \nVersion: \nLicense Key: \nInstallation Path: ', 'Software', 'Install', 3),
('Network Issue', 'Network Issue: [Brief Description]', 'Affected System: \nSymptoms: \nIP Address: \nConnectivity Tests: ', 'Hardware', 'Problem', 2),
('Maintenance Request', 'Scheduled Maintenance: [System Name]', 'System: \nMaintenance Type: \nScheduled Date: \nDowntime Expected: ', 'Hardware', 'Maintenance', 4);
@@ -1,43 +0,0 @@
-- Migration 009: Simplify status workflow
-- Removes "Resolved" status and adds "Pending" status
-- Keeps only: Open, Pending, In Progress, Closed
-- First, update any existing tickets with "Resolved" status to "Closed"
UPDATE tickets SET status = 'Closed' WHERE status = 'Resolved';
-- Delete all existing transitions with "Resolved"
DELETE FROM status_transitions WHERE from_status = 'Resolved' OR to_status = 'Resolved';
-- Clear all existing transitions to rebuild clean workflow
DELETE FROM status_transitions;
-- Define new simplified workflow with Pending status
-- OPEN transitions
INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin) VALUES
('Open', 'Pending', FALSE, FALSE), -- Waiting on external dependency
('Open', 'In Progress', FALSE, FALSE), -- Start work
('Open', 'Closed', TRUE, FALSE); -- Close without work (duplicate, won't fix, etc.)
-- PENDING transitions
INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin) VALUES
('Pending', 'Open', FALSE, FALSE), -- Unblock and reopen
('Pending', 'In Progress', FALSE, FALSE), -- Start work while pending
('Pending', 'Closed', TRUE, FALSE); -- Close while pending
-- IN PROGRESS transitions
INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin) VALUES
('In Progress', 'Open', FALSE, FALSE), -- Stop work, back to queue
('In Progress', 'Pending', FALSE, FALSE), -- Blocked by external dependency
('In Progress', 'Closed', TRUE, FALSE); -- Complete and close
-- CLOSED transitions
INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin) VALUES
('Closed', 'Open', TRUE, FALSE), -- Reopen (requires explanation)
('Closed', 'In Progress', FALSE, FALSE); -- Reopen and start work immediately
-- Verify new transitions
SELECT 'New Status Transitions:' as info;
SELECT from_status, to_status, requires_comment, requires_admin
FROM status_transitions
WHERE is_active = TRUE
ORDER BY from_status, to_status;
-19
View File
@@ -1,19 +0,0 @@
-- Migration 010: Add bulk operations tracking
-- Creates bulk_operations table for admin bulk actions
CREATE TABLE bulk_operations (
operation_id INT AUTO_INCREMENT PRIMARY KEY,
operation_type VARCHAR(50) NOT NULL,
ticket_ids TEXT NOT NULL, -- Comma-separated
performed_by INT NOT NULL,
parameters JSON,
status VARCHAR(20) DEFAULT 'pending',
total_tickets INT,
processed_tickets INT DEFAULT 0,
failed_tickets INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP NULL,
FOREIGN KEY (performed_by) REFERENCES users(user_id),
INDEX idx_performed_by (performed_by),
INDEX idx_created_at (created_at)
);
-18
View File
@@ -1,18 +0,0 @@
-- Migration 010: Expand status column to accommodate longer status names
-- The status column was likely VARCHAR(10) which can't fit "In Progress" or "Pending"
-- Check current column definition
SHOW COLUMNS FROM tickets LIKE 'status';
-- Expand the status column to accommodate longer status names
ALTER TABLE tickets
MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'Open';
-- Verify the change
SHOW COLUMNS FROM tickets LIKE 'status';
-- Show current status distribution
SELECT status, COUNT(*) as count
FROM tickets
GROUP BY status
ORDER BY status;
@@ -1,48 +0,0 @@
-- Migration 011: Create user_preferences table for persistent user settings
-- Stores user-specific preferences like rows per page, default filters, etc.
CREATE TABLE IF NOT EXISTS user_preferences (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
preference_key VARCHAR(100) NOT NULL,
preference_value TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY unique_user_pref (user_id, preference_key),
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
-- Default preferences for existing users
INSERT INTO user_preferences (user_id, preference_key, preference_value)
SELECT user_id, 'rows_per_page', '15' FROM users
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'rows_per_page');
INSERT INTO user_preferences (user_id, preference_key, preference_value)
SELECT user_id, 'default_status_filters', 'Open,Pending,In Progress' FROM users
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'default_status_filters');
INSERT INTO user_preferences (user_id, preference_key, preference_value)
SELECT user_id, 'table_density', 'normal' FROM users
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'table_density');
INSERT INTO user_preferences (user_id, preference_key, preference_value)
SELECT user_id, 'notifications_enabled', '1' FROM users
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'notifications_enabled');
INSERT INTO user_preferences (user_id, preference_key, preference_value)
SELECT user_id, 'sound_effects', '1' FROM users
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'sound_effects');
INSERT INTO user_preferences (user_id, preference_key, preference_value)
SELECT user_id, 'toast_duration', '3000' FROM users
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'toast_duration');
-- Verify table created
SELECT 'User Preferences Table Created' as info;
DESCRIBE user_preferences;
-- Show count of preferences
SELECT 'Default Preferences Inserted' as info;
SELECT preference_key, COUNT(*) as user_count
FROM user_preferences
GROUP BY preference_key
ORDER BY preference_key;
-2
View File
@@ -1,2 +0,0 @@
-- Remove all ticket view tracking records from audit_log
DELETE FROM audit_log WHERE action_type = 'view';
-15
View File
@@ -1,15 +0,0 @@
-- Create saved_filters table for storing user's custom search filters
CREATE TABLE IF NOT EXISTS saved_filters (
filter_id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
filter_name VARCHAR(100) NOT NULL,
filter_criteria JSON NOT NULL,
is_default BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
UNIQUE KEY unique_user_filter_name (user_id, filter_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Create index for faster lookups
CREATE INDEX idx_user_filters ON saved_filters(user_id, is_default);
@@ -1,11 +0,0 @@
-- Migration 013: Add performance indexes for critical queries
-- Index on ticket_comments.ticket_id (foreign key without index)
-- Speeds up comment loading by 10-100x on large tables
CREATE INDEX IF NOT EXISTS idx_ticket_comments_ticket_id
ON ticket_comments(ticket_id);
-- Composite index on audit_log for entity lookups with date sorting
-- Optimizes activity timeline queries
CREATE INDEX IF NOT EXISTS idx_audit_entity_created
ON audit_log(entity_type, entity_id, created_at DESC);
-4
View File
@@ -1,4 +0,0 @@
-- Rollback for migration 013: Remove performance indexes
DROP INDEX IF EXISTS idx_ticket_comments_ticket_id ON ticket_comments;
DROP INDEX IF EXISTS idx_audit_entity_created ON audit_log;
-118
View File
@@ -1,118 +0,0 @@
# Migration 009: Simplify Status Workflow
This migration removes the "Resolved" status and adds a "Pending" status to the ticket system.
## Status Changes
### Before (4 statuses):
- Open
- In Progress
- **Resolved** ❌ (being removed)
- Closed
### After (4 statuses):
- Open
- **Pending** ✅ (new)
- In Progress
- Closed
## What "Pending" Means
**Pending** status indicates a ticket is waiting on:
- External dependencies
- Third-party responses
- Parts/equipment to arrive
- Customer information
- Approval from another team
Unlike "In Progress" which means active work is happening, "Pending" means the ticket is blocked and waiting.
## Running the Migration
On the tinkertickets server, run:
```bash
cd /var/www/html/tinkertickets/migrations
mysql -h 10.10.10.50 -u tinkertickets -p'&*woX!5R$x8Tyrm7zNxC' ticketing_system < 009_simplify_status_workflow.sql
```
## What the Migration Does
1. Updates any existing tickets with status "Resolved" to "Closed"
2. Deletes all status transitions involving "Resolved"
3. Creates new workflow with "Pending" status
4. Sets up the following allowed transitions:
### New Workflow Transitions:
**From Open:**
- → Pending (no comment required)
- → In Progress (no comment required)
- → Closed (requires comment)
**From Pending:**
- → Open (no comment required)
- → In Progress (no comment required)
- → Closed (requires comment)
**From In Progress:**
- → Open (no comment required)
- → Pending (no comment required)
- → Closed (requires comment)
**From Closed:**
- → Open (requires comment - explain why reopening)
- → In Progress (no comment required)
## CSS Updates
The following CSS files have been updated:
-`/assets/css/dashboard.css` - Added `.status-Pending` styling with purple color (#9c27b0) and pause icon
-`/assets/css/ticket.css` - Added `.status-Pending` styling
## Visual Appearance
The Pending status will display as:
```
[⏸ PENDING]
```
- Purple color border and text
- Pause icon (⏸) to indicate waiting state
- Terminal-style glow effect
## Verification
After running the migration, verify:
1. Check that all tickets previously marked "Resolved" are now "Closed":
```sql
SELECT COUNT(*) FROM tickets WHERE status = 'Resolved'; -- Should be 0
SELECT COUNT(*) FROM tickets WHERE status = 'Closed';
```
2. Check new transitions exist:
```sql
SELECT from_status, to_status FROM status_transitions
WHERE from_status = 'Pending' OR to_status = 'Pending'
ORDER BY from_status, to_status;
```
3. Test creating a new ticket and changing its status to Pending in the UI
## Rollback (if needed)
If you need to rollback this migration:
```sql
-- Restore Resolved status transitions
DELETE FROM status_transitions WHERE from_status = 'Pending' OR to_status = 'Pending';
INSERT INTO status_transitions (from_status, to_status, requires_comment) VALUES
('In Progress', 'Resolved', FALSE),
('Resolved', 'Closed', FALSE),
('Resolved', 'In Progress', TRUE),
('Open', 'Resolved', FALSE);
-- Update any Pending tickets to Open
UPDATE tickets SET status = 'Open' WHERE status = 'Pending';
```
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env php
<?php
/**
* Database Migration Runner
*
* Runs SQL migration files in order. Tracks completed migrations
* to prevent re-running them.
*
* Usage:
* php migrate.php # Run all pending migrations
* php migrate.php --status # Show migration status
* php migrate.php --dry-run # Show what would be run without executing
*/
// Prevent web access
if (php_sapi_name() !== 'cli') {
http_response_code(403);
exit('CLI access only');
}
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
$dryRun = in_array('--dry-run', $argv);
$statusOnly = in_array('--status', $argv);
echo "=== Database Migration Runner ===\n\n";
try {
$conn = Database::getConnection();
} catch (Exception $e) {
echo "Error: Could not connect to database: " . $e->getMessage() . "\n";
exit(1);
}
// Create migrations tracking table if it doesn't exist
$createTable = "CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(255) NOT NULL UNIQUE,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_filename (filename)
)";
if (!$conn->query($createTable)) {
echo "Error: Could not create migrations table: " . $conn->error . "\n";
exit(1);
}
// Get list of completed migrations
$completed = [];
$result = $conn->query("SELECT filename FROM migrations ORDER BY id");
while ($row = $result->fetch_assoc()) {
$completed[] = $row['filename'];
}
// Get list of migration files
$migrationsDir = __DIR__;
$files = glob($migrationsDir . '/*.sql');
sort($files);
if (empty($files)) {
echo "No migration files found.\n";
exit(0);
}
if ($statusOnly) {
echo "Migration Status:\n";
echo str_repeat('-', 60) . "\n";
foreach ($files as $file) {
$filename = basename($file);
$status = in_array($filename, $completed) ? '[DONE]' : '[PENDING]';
echo sprintf(" %s %s\n", $status, $filename);
}
exit(0);
}
// Find pending migrations
$pending = [];
foreach ($files as $file) {
$filename = basename($file);
if (!in_array($filename, $completed)) {
$pending[] = $file;
}
}
if (empty($pending)) {
echo "All migrations are up to date.\n";
exit(0);
}
echo sprintf("Found %d pending migration(s):\n", count($pending));
foreach ($pending as $file) {
echo " - " . basename($file) . "\n";
}
echo "\n";
if ($dryRun) {
echo "[DRY RUN] No changes made.\n";
exit(0);
}
// Run pending migrations
$success = 0;
$failed = 0;
foreach ($pending as $file) {
$filename = basename($file);
echo "Running: $filename... ";
$sql = file_get_contents($file);
if ($sql === false) {
echo "FAILED (could not read file)\n";
$failed++;
continue;
}
// Execute migration - handle multiple statements
$conn->begin_transaction();
try {
// Split by semicolon but respect statements properly
// Note: This doesn't handle semicolons in strings, but our migrations are simple
$statements = array_filter(
array_map('trim', explode(';', $sql)),
function($stmt) {
// Remove comments and check if there's actual SQL
$cleaned = preg_replace('/--.*$/m', '', $stmt);
return !empty(trim($cleaned));
}
);
foreach ($statements as $statement) {
if (!$conn->query($statement)) {
// Some "errors" are acceptable (like "index already exists")
$error = $conn->error;
if (strpos($error, 'Duplicate key name') !== false ||
strpos($error, 'already exists') !== false) {
// Index already exists, that's fine
continue;
}
throw new Exception($error);
}
}
// Record the migration
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
$stmt->bind_param('s', $filename);
if (!$stmt->execute()) {
throw new Exception("Could not record migration: " . $conn->error);
}
$conn->commit();
echo "OK\n";
$success++;
} catch (Exception $e) {
$conn->rollback();
echo "FAILED (" . $e->getMessage() . ")\n";
$failed++;
}
}
echo "\n";
echo "=== Migration Complete ===\n";
echo sprintf(" Success: %d\n", $success);
echo sprintf(" Failed: %d\n", $failed);
exit($failed > 0 ? 1 : 0);
-25
View File
@@ -1,25 +0,0 @@
-- Rollback script to undo all SSO integration changes
-- WARNING: This will delete all user data, API keys, and audit logs
-- Drop foreign keys first
ALTER TABLE ticket_comments DROP FOREIGN KEY IF EXISTS fk_comments_user_id;
ALTER TABLE tickets DROP FOREIGN KEY IF EXISTS fk_tickets_created_by;
ALTER TABLE tickets DROP FOREIGN KEY IF EXISTS fk_tickets_updated_by;
ALTER TABLE api_keys DROP FOREIGN KEY IF EXISTS api_keys_ibfk_1;
ALTER TABLE audit_log DROP FOREIGN KEY IF EXISTS audit_log_ibfk_1;
-- Drop columns from existing tables
ALTER TABLE ticket_comments DROP COLUMN IF EXISTS user_id;
ALTER TABLE tickets DROP COLUMN IF EXISTS created_by;
ALTER TABLE tickets DROP COLUMN IF EXISTS updated_by;
ALTER TABLE tickets DROP COLUMN IF EXISTS updated_at;
-- Drop new tables
DROP TABLE IF EXISTS audit_log;
DROP TABLE IF EXISTS api_keys;
DROP TABLE IF EXISTS users;
-- Drop indexes
DROP INDEX IF EXISTS idx_status ON tickets;
DROP INDEX IF EXISTS idx_priority ON tickets;
DROP INDEX IF EXISTS idx_tickets_created_at ON tickets;
-107
View File
@@ -1,107 +0,0 @@
<?php
/**
* Database Migration Runner
* Executes all migration files in order
*/
error_reporting(E_ALL);
ini_set('display_errors', 1);
// Load environment variables
$envFile = dirname(__DIR__) . '/.env';
if (!file_exists($envFile)) {
die("Error: .env file not found at $envFile\n");
}
$envVars = parse_ini_file($envFile);
if (!$envVars) {
die("Error: Could not parse .env file\n");
}
// Connect to database
$conn = new mysqli(
$envVars['DB_HOST'],
$envVars['DB_USER'],
$envVars['DB_PASS'],
$envVars['DB_NAME']
);
if ($conn->connect_error) {
die("Database connection failed: " . $conn->connect_error . "\n");
}
echo "Connected to database: {$envVars['DB_NAME']}\n\n";
// Get all migration files
$migrationFiles = glob(__DIR__ . '/*.sql');
sort($migrationFiles);
// Filter out rollback script
$migrationFiles = array_filter($migrationFiles, function($file) {
return !strpos($file, 'rollback');
});
if (empty($migrationFiles)) {
echo "No migration files found.\n";
exit(0);
}
echo "Found " . count($migrationFiles) . " migration(s):\n";
foreach ($migrationFiles as $file) {
echo " - " . basename($file) . "\n";
}
echo "\n";
// Execute each migration
$successCount = 0;
$errorCount = 0;
foreach ($migrationFiles as $file) {
$filename = basename($file);
echo "Executing: $filename... ";
$sql = file_get_contents($file);
// Split SQL into individual statements
// This handles multi-statement migrations
if ($conn->multi_query($sql)) {
do {
// Store first result set
if ($result = $conn->store_result()) {
$result->free();
}
// Check for errors
if ($conn->errno) {
echo "FAILED\n";
echo " Error: " . $conn->error . "\n";
$errorCount++;
break;
}
} while ($conn->more_results() && $conn->next_result());
// If we got through all results without error
if (!$conn->errno) {
echo "OK\n";
$successCount++;
}
} else {
echo "FAILED\n";
echo " Error: " . $conn->error . "\n";
$errorCount++;
}
}
echo "\n";
echo "Migration Summary:\n";
echo " Success: $successCount\n";
echo " Errors: $errorCount\n";
if ($errorCount > 0) {
echo "\nSome migrations failed. Please review errors above.\n";
exit(1);
} else {
echo "\nAll migrations completed successfully!\n";
exit(0);
}
$conn->close();
+21 -10
View File
@@ -1,11 +1,14 @@
<?php <?php
/** /**
* ApiKeyModel - Handles API key generation and validation * ApiKeyModel - Handles API key generation and validation
*/ */
class ApiKeyModel { class ApiKeyModel
{
private $conn; private $conn;
public function __construct($conn) { public function __construct($conn)
{
$this->conn = $conn; $this->conn = $conn;
} }
@@ -17,7 +20,8 @@ class ApiKeyModel {
* @param int|null $expiresInDays Number of days until expiration (null for no expiration) * @param int|null $expiresInDays Number of days until expiration (null for no expiration)
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error' * @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error'
*/ */
public function createKey($keyName, $createdBy, $expiresInDays = null) { public function createKey($keyName, $createdBy, $expiresInDays = null)
{
// Generate random API key (32 bytes = 64 hex characters) // Generate random API key (32 bytes = 64 hex characters)
$apiKey = bin2hex(random_bytes(32)); $apiKey = bin2hex(random_bytes(32));
@@ -67,7 +71,8 @@ class ApiKeyModel {
* @param string $apiKey Plaintext API key to validate * @param string $apiKey Plaintext API key to validate
* @return array|null API key record if valid, null if invalid * @return array|null API key record if valid, null if invalid
*/ */
public function validateKey($apiKey) { public function validateKey($apiKey)
{
if (empty($apiKey)) { if (empty($apiKey)) {
return null; return null;
} }
@@ -111,7 +116,8 @@ class ApiKeyModel {
* @param int $keyId API key ID * @param int $keyId API key ID
* @return bool Success status * @return bool Success status
*/ */
private function updateLastUsed($keyId) { private function updateLastUsed($keyId)
{
$stmt = $this->conn->prepare("UPDATE api_keys SET last_used = NOW() WHERE api_key_id = ?"); $stmt = $this->conn->prepare("UPDATE api_keys SET last_used = NOW() WHERE api_key_id = ?");
$stmt->bind_param("i", $keyId); $stmt->bind_param("i", $keyId);
$success = $stmt->execute(); $success = $stmt->execute();
@@ -125,7 +131,8 @@ class ApiKeyModel {
* @param int $keyId API key ID * @param int $keyId API key ID
* @return bool Success status * @return bool Success status
*/ */
public function revokeKey($keyId) { public function revokeKey($keyId)
{
$stmt = $this->conn->prepare("UPDATE api_keys SET is_active = 0 WHERE api_key_id = ?"); $stmt = $this->conn->prepare("UPDATE api_keys SET is_active = 0 WHERE api_key_id = ?");
$stmt->bind_param("i", $keyId); $stmt->bind_param("i", $keyId);
$success = $stmt->execute(); $success = $stmt->execute();
@@ -139,7 +146,8 @@ class ApiKeyModel {
* @param int $keyId API key ID * @param int $keyId API key ID
* @return bool Success status * @return bool Success status
*/ */
public function deleteKey($keyId) { public function deleteKey($keyId)
{
$stmt = $this->conn->prepare("DELETE FROM api_keys WHERE api_key_id = ?"); $stmt = $this->conn->prepare("DELETE FROM api_keys WHERE api_key_id = ?");
$stmt->bind_param("i", $keyId); $stmt->bind_param("i", $keyId);
$success = $stmt->execute(); $success = $stmt->execute();
@@ -152,7 +160,8 @@ class ApiKeyModel {
* *
* @return array Array of API key records (without hashes) * @return array Array of API key records (without hashes)
*/ */
public function getAllKeys() { public function getAllKeys()
{
$stmt = $this->conn->prepare( $stmt = $this->conn->prepare(
"SELECT ak.*, u.username, u.display_name "SELECT ak.*, u.username, u.display_name
FROM api_keys ak FROM api_keys ak
@@ -179,7 +188,8 @@ class ApiKeyModel {
* @param int $keyId API key ID * @param int $keyId API key ID
* @return array|null API key record (without hash) or null if not found * @return array|null API key record (without hash) or null if not found
*/ */
public function getKeyById($keyId) { public function getKeyById($keyId)
{
$stmt = $this->conn->prepare( $stmt = $this->conn->prepare(
"SELECT ak.*, u.username, u.display_name "SELECT ak.*, u.username, u.display_name
FROM api_keys ak FROM api_keys ak
@@ -208,7 +218,8 @@ class ApiKeyModel {
* @param int $userId User ID * @param int $userId User ID
* @return array Array of API key records * @return array Array of API key records
*/ */
public function getKeysByUser($userId) { public function getKeysByUser($userId)
{
$stmt = $this->conn->prepare( $stmt = $this->conn->prepare(
"SELECT * FROM api_keys WHERE created_by = ? ORDER BY created_at DESC" "SELECT * FROM api_keys WHERE created_by = ? ORDER BY created_at DESC"
); );
+208
View File
@@ -0,0 +1,208 @@
<?php
/**
* AttachmentModel - Handles ticket file attachments
*/
class AttachmentModel
{
private $conn;
public function __construct($conn)
{
$this->conn = $conn;
}
/**
* Get all attachments for a ticket
*/
public function getAttachments($ticketId)
{
$sql = "SELECT a.*, u.username, u.display_name
FROM ticket_attachments a
LEFT JOIN users u ON a.uploaded_by = u.user_id
WHERE a.ticket_id = ?
ORDER BY a.uploaded_at DESC";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$attachments = [];
while ($row = $result->fetch_assoc()) {
$attachments[] = $row;
}
$stmt->close();
return $attachments;
}
/**
* Get a single attachment by ID
*/
public function getAttachment($attachmentId)
{
$sql = "SELECT a.*, u.username, u.display_name
FROM ticket_attachments a
LEFT JOIN users u ON a.uploaded_by = u.user_id
WHERE a.attachment_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $attachmentId);
$stmt->execute();
$result = $stmt->get_result();
$attachment = $result->fetch_assoc();
$stmt->close();
return $attachment;
}
/**
* Add a new attachment record
*/
public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy)
{
$sql = "INSERT INTO ticket_attachments (ticket_id, filename, original_filename, file_size, mime_type, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("sssisi", $ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy);
$result = $stmt->execute();
if ($result) {
$attachmentId = $this->conn->insert_id;
$stmt->close();
return $attachmentId;
}
$stmt->close();
return false;
}
/**
* Delete an attachment record
*/
public function deleteAttachment($attachmentId)
{
$sql = "DELETE FROM ticket_attachments WHERE attachment_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $attachmentId);
$result = $stmt->execute();
$stmt->close();
return $result;
}
/**
* Get total attachment size for a ticket
*/
public function getTotalSizeForTicket($ticketId)
{
$sql = "SELECT COALESCE(SUM(file_size), 0) as total_size
FROM ticket_attachments
WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
$stmt->close();
return (int)$row['total_size'];
}
/**
* Get attachment count for a ticket
*/
public function getAttachmentCount($ticketId)
{
$sql = "SELECT COUNT(*) as count FROM ticket_attachments WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
$stmt->close();
return (int)$row['count'];
}
/**
* Check if user can delete attachment (owner or admin)
*/
public function canUserDelete($attachmentId, $userId, $isAdmin = false)
{
if ($isAdmin) {
return true;
}
$attachment = $this->getAttachment($attachmentId);
return $attachment && (int)$attachment['uploaded_by'] === (int)$userId;
}
/**
* Format file size for display
*/
public static function formatFileSize($bytes)
{
if ($bytes >= 1073741824) {
return number_format($bytes / 1073741824, 2) . ' GB';
} elseif ($bytes >= 1048576) {
return number_format($bytes / 1048576, 2) . ' MB';
} elseif ($bytes >= 1024) {
return number_format($bytes / 1024, 2) . ' KB';
} else {
return $bytes . ' bytes';
}
}
/**
* Get file icon based on mime type
*/
public static function getFileIcon($mimeType)
{
if (strpos($mimeType, 'image/') === 0) {
return '🖼️';
} elseif (strpos($mimeType, 'video/') === 0) {
return '🎬';
} elseif (strpos($mimeType, 'audio/') === 0) {
return '🎵';
} elseif ($mimeType === 'application/pdf') {
return '📄';
} elseif (strpos($mimeType, 'text/') === 0) {
return '📝';
} elseif (in_array($mimeType, ['application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed', 'application/gzip'])) {
return '📦';
} elseif (in_array($mimeType, ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'])) {
return '📘';
} elseif (in_array($mimeType, ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])) {
return '📊';
} else {
return '📎';
}
}
/**
* Validate file type against allowed types
*/
public static function isAllowedType($mimeType)
{
$allowedTypes = $GLOBALS['config']['ALLOWED_FILE_TYPES'] ?? [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
'text/plain', 'text/csv',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed',
'application/json', 'application/xml'
];
return in_array($mimeType, $allowedTypes);
}
}
+310 -48
View File
@@ -1,14 +1,104 @@
<?php <?php
/** /**
* AuditLogModel - Handles audit trail logging for all user actions * AuditLogModel - Handles audit trail logging for all user actions
*/ */
class AuditLogModel { class AuditLogModel
{
private $conn; private $conn;
public function __construct($conn) { /** @var int Maximum allowed limit for pagination */
private const MAX_LIMIT = 1000;
/** @var int Default limit for pagination */
private const DEFAULT_LIMIT = 100;
/** @var array Allowed action types for filtering */
private const VALID_ACTION_TYPES = [
'create', 'update', 'delete', 'view', 'security_event',
'login', 'logout', 'assign', 'comment', 'bulk_update'
];
/** @var array Allowed entity types for filtering */
private const VALID_ENTITY_TYPES = [
'ticket', 'comment', 'user', 'api_key', 'security',
'template', 'attachment', 'group'
];
public function __construct($conn)
{
$this->conn = $conn; $this->conn = $conn;
} }
/**
* Validate and sanitize pagination limit
*
* @param int $limit Requested limit
* @return int Validated limit
*/
private function validateLimit(int $limit): int
{
if ($limit < 1) {
return self::DEFAULT_LIMIT;
}
return min($limit, self::MAX_LIMIT);
}
/**
* Validate and sanitize pagination offset
*
* @param int $offset Requested offset
* @return int Validated offset (non-negative)
*/
private function validateOffset(int $offset): int
{
return max(0, $offset);
}
/**
* Validate date format (YYYY-MM-DD)
*
* @param string $date Date string
* @return string|null Validated date or null if invalid
*/
private function validateDate(string $date): ?string
{
// Check format
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return null;
}
// Verify it's a valid date
$parts = explode('-', $date);
if (!checkdate((int)$parts[1], (int)$parts[2], (int)$parts[0])) {
return null;
}
return $date;
}
/**
* Validate action type
*
* @param string $actionType Action type to validate
* @return bool True if valid
*/
private function isValidActionType(string $actionType): bool
{
return in_array($actionType, self::VALID_ACTION_TYPES, true);
}
/**
* Validate entity type
*
* @param string $entityType Entity type to validate
* @return bool True if valid
*/
private function isValidEntityType(string $entityType): bool
{
return in_array($entityType, self::VALID_ENTITY_TYPES, true);
}
/** /**
* Log an action to the audit trail * Log an action to the audit trail
* *
@@ -20,7 +110,8 @@ class AuditLogModel {
* @param string|null $ipAddress IP address of the user * @param string|null $ipAddress IP address of the user
* @return bool Success status * @return bool Success status
*/ */
public function log($userId, $actionType, $entityType, $entityId = null, $details = null, $ipAddress = null) { public function log($userId, $actionType, $entityType, $entityId = null, $details = null, $ipAddress = null)
{
// Convert details array to JSON // Convert details array to JSON
$detailsJson = null; $detailsJson = null;
if ($details !== null) { if ($details !== null) {
@@ -52,7 +143,10 @@ class AuditLogModel {
* @param int $limit Maximum number of logs to return * @param int $limit Maximum number of logs to return
* @return array Array of audit log records * @return array Array of audit log records
*/ */
public function getLogsByEntity($entityType, $entityId, $limit = 100) { public function getLogsByEntity($entityType, $entityId, $limit = 100)
{
$limit = $this->validateLimit((int)$limit);
$stmt = $this->conn->prepare( $stmt = $this->conn->prepare(
"SELECT al.*, u.username, u.display_name "SELECT al.*, u.username, u.display_name
FROM audit_log al FROM audit_log al
@@ -85,7 +179,11 @@ class AuditLogModel {
* @param int $limit Maximum number of logs to return * @param int $limit Maximum number of logs to return
* @return array Array of audit log records * @return array Array of audit log records
*/ */
public function getLogsByUser($userId, $limit = 100) { public function getLogsByUser($userId, $limit = 100)
{
$limit = $this->validateLimit((int)$limit);
$userId = max(0, (int)$userId);
$stmt = $this->conn->prepare( $stmt = $this->conn->prepare(
"SELECT al.*, u.username, u.display_name "SELECT al.*, u.username, u.display_name
FROM audit_log al FROM audit_log al
@@ -118,7 +216,11 @@ class AuditLogModel {
* @param int $offset Offset for pagination * @param int $offset Offset for pagination
* @return array Array of audit log records * @return array Array of audit log records
*/ */
public function getRecentLogs($limit = 50, $offset = 0) { public function getRecentLogs($limit = 50, $offset = 0)
{
$limit = $this->validateLimit((int)$limit);
$offset = $this->validateOffset((int)$offset);
$stmt = $this->conn->prepare( $stmt = $this->conn->prepare(
"SELECT al.*, u.username, u.display_name "SELECT al.*, u.username, u.display_name
FROM audit_log al FROM audit_log al
@@ -150,7 +252,15 @@ class AuditLogModel {
* @param int $limit Maximum number of logs to return * @param int $limit Maximum number of logs to return
* @return array Array of audit log records * @return array Array of audit log records
*/ */
public function getLogsByAction($actionType, $limit = 100) { public function getLogsByAction($actionType, $limit = 100)
{
$limit = $this->validateLimit((int)$limit);
// Validate action type to prevent unexpected queries
if (!$this->isValidActionType($actionType)) {
return [];
}
$stmt = $this->conn->prepare( $stmt = $this->conn->prepare(
"SELECT al.*, u.username, u.display_name "SELECT al.*, u.username, u.display_name
FROM audit_log al FROM audit_log al
@@ -181,7 +291,8 @@ class AuditLogModel {
* *
* @return int Total count * @return int Total count
*/ */
public function getTotalCount() { public function getTotalCount()
{
$result = $this->conn->query("SELECT COUNT(*) as count FROM audit_log"); $result = $this->conn->query("SELECT COUNT(*) as count FROM audit_log");
$row = $result->fetch_assoc(); $row = $result->fetch_assoc();
return (int)$row['count']; return (int)$row['count'];
@@ -193,7 +304,8 @@ class AuditLogModel {
* @param int $daysToKeep Number of days of logs to keep * @param int $daysToKeep Number of days of logs to keep
* @return int Number of deleted records * @return int Number of deleted records
*/ */
public function deleteOldLogs($daysToKeep = 90) { public function deleteOldLogs($daysToKeep = 90)
{
$stmt = $this->conn->prepare( $stmt = $this->conn->prepare(
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)" "DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
); );
@@ -210,7 +322,8 @@ class AuditLogModel {
* *
* @return string Client IP address * @return string Client IP address
*/ */
private function getClientIP() { private function getClientIP()
{
$ipAddress = ''; $ipAddress = '';
// Check for proxy headers // Check for proxy headers
@@ -239,7 +352,8 @@ class AuditLogModel {
* @param array $ticketData Ticket data * @param array $ticketData Ticket data
* @return bool Success status * @return bool Success status
*/ */
public function logTicketCreate($userId, $ticketId, $ticketData) { public function logTicketCreate($userId, $ticketId, $ticketData)
{
return $this->log( return $this->log(
$userId, $userId,
'create', 'create',
@@ -257,7 +371,8 @@ class AuditLogModel {
* @param array $changes Array of changed fields * @param array $changes Array of changed fields
* @return bool Success status * @return bool Success status
*/ */
public function logTicketUpdate($userId, $ticketId, $changes) { public function logTicketUpdate($userId, $ticketId, $changes)
{
return $this->log($userId, 'update', 'ticket', $ticketId, $changes); return $this->log($userId, 'update', 'ticket', $ticketId, $changes);
} }
@@ -269,10 +384,11 @@ class AuditLogModel {
* @param string $ticketId Associated ticket ID * @param string $ticketId Associated ticket ID
* @return bool Success status * @return bool Success status
*/ */
public function logCommentCreate($userId, $commentId, $ticketId) { public function logCommentCreate($userId, $commentId, $ticketId)
{
return $this->log( return $this->log(
$userId, $userId,
'create', 'comment',
'comment', 'comment',
(string)$commentId, (string)$commentId,
['ticket_id' => $ticketId] ['ticket_id' => $ticketId]
@@ -286,10 +402,124 @@ class AuditLogModel {
* @param string $ticketId Ticket ID * @param string $ticketId Ticket ID
* @return bool Success status * @return bool Success status
*/ */
public function logTicketView($userId, $ticketId) { public function logTicketView($userId, $ticketId)
{
return $this->log($userId, 'view', 'ticket', $ticketId); return $this->log($userId, 'view', 'ticket', $ticketId);
} }
// ========================================
// Security Event Logging Methods
// ========================================
/**
* Log a security event
*
* @param string $eventType Type of security event
* @param array $details Additional details
* @param int|null $userId User ID if known
* @return bool Success status
*/
public function logSecurityEvent($eventType, $details = [], $userId = null)
{
$details['event_type'] = $eventType;
$details['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
return $this->log($userId, 'security_event', 'security', null, $details);
}
/**
* Log a failed authentication attempt
*
* @param string $username Username attempted
* @param string $reason Reason for failure
* @return bool Success status
*/
public function logFailedAuth($username, $reason = 'Invalid credentials')
{
return $this->logSecurityEvent('failed_auth', [
'username' => $username,
'reason' => $reason
]);
}
/**
* Log a CSRF token failure
*
* @param string $endpoint The endpoint that was accessed
* @param int|null $userId User ID if session exists
* @return bool Success status
*/
public function logCsrfFailure($endpoint, $userId = null)
{
return $this->logSecurityEvent('csrf_failure', [
'endpoint' => $endpoint,
'method' => $_SERVER['REQUEST_METHOD'] ?? 'Unknown'
], $userId);
}
/**
* Log a rate limit exceeded event
*
* @param string $endpoint The endpoint that was rate limited
* @param int|null $userId User ID if session exists
* @return bool Success status
*/
public function logRateLimitExceeded($endpoint, $userId = null)
{
return $this->logSecurityEvent('rate_limit_exceeded', [
'endpoint' => $endpoint
], $userId);
}
/**
* Log an unauthorized access attempt
*
* @param string $resource The resource that was accessed
* @param int|null $userId User ID if session exists
* @return bool Success status
*/
public function logUnauthorizedAccess($resource, $userId = null)
{
return $this->logSecurityEvent('unauthorized_access', [
'resource' => $resource
], $userId);
}
/**
* Get security events (for admin review)
*
* @param int $limit Maximum number of events
* @param int $offset Offset for pagination
* @return array Security events
*/
public function getSecurityEvents($limit = 100, $offset = 0)
{
$limit = $this->validateLimit((int)$limit);
$offset = $this->validateOffset((int)$offset);
$stmt = $this->conn->prepare(
"SELECT al.*, u.username, u.display_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
WHERE al.action_type = 'security_event'
ORDER BY al.created_at DESC
LIMIT ? OFFSET ?"
);
$stmt->bind_param("ii", $limit, $offset);
$stmt->execute();
$result = $stmt->get_result();
$events = [];
while ($row = $result->fetch_assoc()) {
if ($row['details']) {
$row['details'] = json_decode($row['details'], true);
}
$events[] = $row;
}
$stmt->close();
return $events;
}
/** /**
* Get formatted timeline for a specific ticket * Get formatted timeline for a specific ticket
* Includes all ticket updates and comments * Includes all ticket updates and comments
@@ -297,7 +527,8 @@ class AuditLogModel {
* @param string $ticketId Ticket ID * @param string $ticketId Ticket ID
* @return array Timeline events * @return array Timeline events
*/ */
public function getTicketTimeline($ticketId) { public function getTicketTimeline($ticketId)
{
$stmt = $this->conn->prepare( $stmt = $this->conn->prepare(
"SELECT al.*, u.username, u.display_name "SELECT al.*, u.username, u.display_name
FROM audit_log al FROM audit_log al
@@ -330,60 +561,91 @@ class AuditLogModel {
* @param int $offset Offset for pagination * @param int $offset Offset for pagination
* @return array Array containing logs and total count * @return array Array containing logs and total count
*/ */
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0) { public function getFilteredLogs($filters = [], $limit = 50, $offset = 0)
{
// Validate pagination parameters
$limit = $this->validateLimit((int)$limit);
$offset = $this->validateOffset((int)$offset);
$whereConditions = []; $whereConditions = [];
$params = []; $params = [];
$paramTypes = ''; $paramTypes = '';
// Action type filter // Action type filter - validate each action type
if (!empty($filters['action_type'])) { if (!empty($filters['action_type'])) {
$actions = explode(',', $filters['action_type']); $actions = array_filter(
$placeholders = str_repeat('?,', count($actions) - 1) . '?'; array_map('trim', explode(',', $filters['action_type'])),
$whereConditions[] = "al.action_type IN ($placeholders)"; fn($action) => $this->isValidActionType($action)
$params = array_merge($params, $actions); );
$paramTypes .= str_repeat('s', count($actions)); if (!empty($actions)) {
$placeholders = str_repeat('?,', count($actions) - 1) . '?';
$whereConditions[] = "al.action_type IN ($placeholders)";
$params = array_merge($params, array_values($actions));
$paramTypes .= str_repeat('s', count($actions));
}
} }
// Entity type filter // Entity type filter - validate each entity type
if (!empty($filters['entity_type'])) { if (!empty($filters['entity_type'])) {
$entities = explode(',', $filters['entity_type']); $entities = array_filter(
$placeholders = str_repeat('?,', count($entities) - 1) . '?'; array_map('trim', explode(',', $filters['entity_type'])),
$whereConditions[] = "al.entity_type IN ($placeholders)"; fn($entity) => $this->isValidEntityType($entity)
$params = array_merge($params, $entities); );
$paramTypes .= str_repeat('s', count($entities)); if (!empty($entities)) {
$placeholders = str_repeat('?,', count($entities) - 1) . '?';
$whereConditions[] = "al.entity_type IN ($placeholders)";
$params = array_merge($params, array_values($entities));
$paramTypes .= str_repeat('s', count($entities));
}
} }
// User filter // User filter - validate as positive integer
if (!empty($filters['user_id'])) { if (!empty($filters['user_id'])) {
$whereConditions[] = "al.user_id = ?"; $userId = (int)$filters['user_id'];
$params[] = (int)$filters['user_id']; if ($userId > 0) {
$paramTypes .= 'i'; $whereConditions[] = "al.user_id = ?";
$params[] = $userId;
$paramTypes .= 'i';
}
} }
// Entity ID filter (for specific ticket/comment) // Entity ID filter - sanitize (alphanumeric and dashes only)
if (!empty($filters['entity_id'])) { if (!empty($filters['entity_id'])) {
$whereConditions[] = "al.entity_id = ?"; $entityId = preg_replace('/[^a-zA-Z0-9_-]/', '', $filters['entity_id']);
$params[] = $filters['entity_id']; if (!empty($entityId)) {
$paramTypes .= 's'; $whereConditions[] = "al.entity_id = ?";
$params[] = $entityId;
$paramTypes .= 's';
}
} }
// Date range filters // Date range filters - validate format
if (!empty($filters['date_from'])) { if (!empty($filters['date_from'])) {
$whereConditions[] = "DATE(al.created_at) >= ?"; $dateFrom = $this->validateDate($filters['date_from']);
$params[] = $filters['date_from']; if ($dateFrom !== null) {
$paramTypes .= 's'; $whereConditions[] = "DATE(al.created_at) >= ?";
$params[] = $dateFrom;
$paramTypes .= 's';
}
} }
if (!empty($filters['date_to'])) { if (!empty($filters['date_to'])) {
$whereConditions[] = "DATE(al.created_at) <= ?"; $dateTo = $this->validateDate($filters['date_to']);
$params[] = $filters['date_to']; if ($dateTo !== null) {
$paramTypes .= 's'; $whereConditions[] = "DATE(al.created_at) <= ?";
$params[] = $dateTo;
$paramTypes .= 's';
}
} }
// IP address filter // IP address filter - validate format (basic IP pattern)
if (!empty($filters['ip_address'])) { if (!empty($filters['ip_address'])) {
$whereConditions[] = "al.ip_address LIKE ?"; // Allow partial IP matching but sanitize input
$params[] = '%' . $filters['ip_address'] . '%'; $ipAddress = preg_replace('/[^0-9.:a-fA-F]/', '', $filters['ip_address']);
$paramTypes .= 's'; if (!empty($ipAddress) && strlen($ipAddress) <= 45) { // Max IPv6 length
$whereConditions[] = "al.ip_address LIKE ?";
$params[] = '%' . $ipAddress . '%';
$paramTypes .= 's';
}
} }
// Build WHERE clause // Build WHERE clause
+165 -62
View File
@@ -1,11 +1,14 @@
<?php <?php
/** /**
* BulkOperationsModel - Handles bulk ticket operations (Admin only) * BulkOperationsModel - Handles bulk ticket operations (Admin only)
*/ */
class BulkOperationsModel { class BulkOperationsModel
{
private $conn; private $conn;
public function __construct($conn) { public function __construct($conn)
{
$this->conn = $conn; $this->conn = $conn;
} }
@@ -18,7 +21,16 @@ class BulkOperationsModel {
* @param array|null $parameters Operation parameters * @param array|null $parameters Operation parameters
* @return int|false Operation ID or false on failure * @return int|false Operation ID or false on failure
*/ */
public function createBulkOperation($type, $ticketIds, $userId, $parameters = null) { public function createBulkOperation($type, $ticketIds, $userId, $parameters = null)
{
// Validate ticket IDs to prevent injection via implode
$ticketIds = array_values(array_filter(
array_map('strval', $ticketIds),
fn($id) => preg_match('/^[0-9]+$/', $id)
));
if (empty($ticketIds)) {
return false;
}
$ticketIdsStr = implode(',', $ticketIds); $ticketIdsStr = implode(',', $ticketIds);
$totalTickets = count($ticketIds); $totalTickets = count($ticketIds);
$parametersJson = $parameters ? json_encode($parameters) : null; $parametersJson = $parameters ? json_encode($parameters) : null;
@@ -41,10 +53,15 @@ class BulkOperationsModel {
/** /**
* Process a bulk operation * Process a bulk operation
* *
* Uses database transaction to ensure atomicity - either all tickets
* are updated or none are (on failure, changes are rolled back).
*
* @param int $operationId Operation ID * @param int $operationId Operation ID
* @param bool $atomic If true, rollback all changes on any failure
* @return array Result with processed and failed counts * @return array Result with processed and failed counts
*/ */
public function processBulkOperation($operationId) { public function processBulkOperation($operationId, bool $atomic = false)
{
// Get operation details // Get operation details
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?"; $sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
@@ -62,6 +79,7 @@ class BulkOperationsModel {
$parameters = $operation['parameters'] ? json_decode($operation['parameters'], true) : []; $parameters = $operation['parameters'] ? json_decode($operation['parameters'], true) : [];
$processed = 0; $processed = 0;
$failed = 0; $failed = 0;
$errors = [];
// Load required models // Load required models
require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/TicketModel.php';
@@ -73,17 +91,21 @@ class BulkOperationsModel {
// Batch load all tickets in one query to eliminate N+1 problem // Batch load all tickets in one query to eliminate N+1 problem
$ticketsById = $ticketModel->getTicketsByIds($ticketIds); $ticketsById = $ticketModel->getTicketsByIds($ticketIds);
foreach ($ticketIds as $ticketId) { // Start transaction for data consistency
$ticketId = trim($ticketId); $this->conn->begin_transaction();
$success = false;
try { try {
switch ($operation['operation_type']) { foreach ($ticketIds as $ticketId) {
case 'bulk_close': $ticketId = trim($ticketId);
// Get current ticket from pre-loaded batch $success = false;
$currentTicket = $ticketsById[$ticketId] ?? null;
if ($currentTicket) { try {
$success = $ticketModel->updateTicket([ switch ($operation['operation_type']) {
case 'bulk_close':
// Get current ticket from pre-loaded batch
$currentTicket = $ticketsById[$ticketId] ?? null;
if ($currentTicket) {
$updateResult = $ticketModel->updateTicket([
'ticket_id' => $ticketId, 'ticket_id' => $ticketId,
'title' => $currentTicket['title'], 'title' => $currentTicket['title'],
'description' => $currentTicket['description'], 'description' => $currentTicket['description'],
@@ -91,30 +113,41 @@ class BulkOperationsModel {
'type' => $currentTicket['type'], 'type' => $currentTicket['type'],
'status' => 'Closed', 'status' => 'Closed',
'priority' => $currentTicket['priority'] 'priority' => $currentTicket['priority']
], $operation['performed_by']); ], $operation['performed_by']);
$success = $updateResult['success'];
if ($success) { if ($success) {
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId, $auditLogModel->log(
['status' => 'Closed', 'bulk_operation_id' => $operationId]); $operation['performed_by'],
'update',
'ticket',
$ticketId,
['status' => 'Closed', 'bulk_operation_id' => $operationId]
);
}
} }
} break;
break;
case 'bulk_assign': case 'bulk_assign':
if (isset($parameters['assigned_to'])) { if (isset($parameters['assigned_to'])) {
$success = $ticketModel->assignTicket($ticketId, $parameters['assigned_to'], $operation['performed_by']); $success = $ticketModel->assignTicket($ticketId, $parameters['assigned_to'], $operation['performed_by']);
if ($success) { if ($success) {
$auditLogModel->log($operation['performed_by'], 'assign', 'ticket', $ticketId, $auditLogModel->log(
['assigned_to' => $parameters['assigned_to'], 'bulk_operation_id' => $operationId]); $operation['performed_by'],
'assign',
'ticket',
$ticketId,
['assigned_to' => $parameters['assigned_to'], 'bulk_operation_id' => $operationId]
);
}
} }
} break;
break;
case 'bulk_priority': case 'bulk_priority':
if (isset($parameters['priority'])) { if (isset($parameters['priority'])) {
$currentTicket = $ticketsById[$ticketId] ?? null; $currentTicket = $ticketsById[$ticketId] ?? null;
if ($currentTicket) { if ($currentTicket) {
$success = $ticketModel->updateTicket([ $updateResult = $ticketModel->updateTicket([
'ticket_id' => $ticketId, 'ticket_id' => $ticketId,
'title' => $currentTicket['title'], 'title' => $currentTicket['title'],
'description' => $currentTicket['description'], 'description' => $currentTicket['description'],
@@ -122,21 +155,27 @@ class BulkOperationsModel {
'type' => $currentTicket['type'], 'type' => $currentTicket['type'],
'status' => $currentTicket['status'], 'status' => $currentTicket['status'],
'priority' => $parameters['priority'] 'priority' => $parameters['priority']
], $operation['performed_by']); ], $operation['performed_by']);
$success = $updateResult['success'];
if ($success) { if ($success) {
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId, $auditLogModel->log(
['priority' => $parameters['priority'], 'bulk_operation_id' => $operationId]); $operation['performed_by'],
'update',
'ticket',
$ticketId,
['priority' => $parameters['priority'], 'bulk_operation_id' => $operationId]
);
}
} }
} }
} break;
break;
case 'bulk_status': case 'bulk_status':
if (isset($parameters['status'])) { if (isset($parameters['status'])) {
$currentTicket = $ticketsById[$ticketId] ?? null; $currentTicket = $ticketsById[$ticketId] ?? null;
if ($currentTicket) { if ($currentTicket) {
$success = $ticketModel->updateTicket([ $updateResult = $ticketModel->updateTicket([
'ticket_id' => $ticketId, 'ticket_id' => $ticketId,
'title' => $currentTicket['title'], 'title' => $currentTicket['title'],
'description' => $currentTicket['description'], 'description' => $currentTicket['description'],
@@ -144,37 +183,99 @@ class BulkOperationsModel {
'type' => $currentTicket['type'], 'type' => $currentTicket['type'],
'status' => $parameters['status'], 'status' => $parameters['status'],
'priority' => $currentTicket['priority'] 'priority' => $currentTicket['priority']
], $operation['performed_by']); ], $operation['performed_by']);
$success = $updateResult['success'];
if ($success) { if ($success) {
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId, $auditLogModel->log(
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]); $operation['performed_by'],
'update',
'ticket',
$ticketId,
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]
);
}
} }
} }
} break;
break;
}
if ($success) { case 'bulk_delete':
$processed++; $success = $ticketModel->deleteTicket($ticketId);
} else { if ($success) {
$auditLogModel->log(
$operation['performed_by'],
'delete',
'ticket',
$ticketId,
['bulk_operation_id' => $operationId]
);
}
break;
}
if ($success) {
$processed++;
} else {
$failed++;
$errors[] = "Ticket $ticketId: Update failed";
}
} catch (Exception $e) {
$failed++; $failed++;
$errors[] = "Ticket $ticketId: " . $e->getMessage();
error_log("Bulk operation error for ticket $ticketId: " . $e->getMessage());
} }
} catch (Exception $e) {
$failed++;
error_log("Bulk operation error for ticket $ticketId: " . $e->getMessage());
} }
// If atomic mode and any failures, rollback everything
if ($atomic && $failed > 0) {
$this->conn->rollback();
error_log("Bulk operation $operationId rolled back due to $failed failures");
// Update operation status as failed
$sql = "UPDATE bulk_operations SET status = 'failed', processed_tickets = 0, failed_tickets = ?,
completed_at = NOW() WHERE operation_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ii", $failed, $operationId);
$stmt->execute();
$stmt->close();
return [
'processed' => 0,
'failed' => $failed,
'rolled_back' => true,
'errors' => $errors
];
}
// Commit the transaction
$this->conn->commit();
} catch (Exception $e) {
// Rollback on any unexpected error
$this->conn->rollback();
error_log("Bulk operation $operationId failed with exception: " . $e->getMessage());
return [
'processed' => 0,
'failed' => count($ticketIds),
'error' => 'Transaction failed: ' . $e->getMessage(),
'rolled_back' => true
];
} }
// Update operation status // Update operation status
$sql = "UPDATE bulk_operations SET status = 'completed', processed_tickets = ?, failed_tickets = ?, $status = $failed > 0 ? 'completed_with_errors' : 'completed';
$sql = "UPDATE bulk_operations SET status = ?, processed_tickets = ?, failed_tickets = ?,
completed_at = NOW() WHERE operation_id = ?"; completed_at = NOW() WHERE operation_id = ?";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
$stmt->bind_param("iii", $processed, $failed, $operationId); $stmt->bind_param("siii", $status, $processed, $failed, $operationId);
$stmt->execute(); $stmt->execute();
$stmt->close(); $stmt->close();
return ['processed' => $processed, 'failed' => $failed]; $result = ['processed' => $processed, 'failed' => $failed];
if (!empty($errors)) {
$result['errors'] = $errors;
}
return $result;
} }
/** /**
@@ -183,7 +284,8 @@ class BulkOperationsModel {
* @param int $operationId Operation ID * @param int $operationId Operation ID
* @return array|null Operation record or null * @return array|null Operation record or null
*/ */
public function getOperationById($operationId) { public function getOperationById($operationId)
{
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?"; $sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $operationId); $stmt->bind_param("i", $operationId);
@@ -201,7 +303,8 @@ class BulkOperationsModel {
* @param int $limit Result limit * @param int $limit Result limit
* @return array Array of operations * @return array Array of operations
*/ */
public function getOperationsByUser($userId, $limit = 50) { public function getOperationsByUser($userId, $limit = 50)
{
$sql = "SELECT * FROM bulk_operations WHERE performed_by = ? $sql = "SELECT * FROM bulk_operations WHERE performed_by = ?
ORDER BY created_at DESC LIMIT ?"; ORDER BY created_at DESC LIMIT ?";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
+377 -32
View File
@@ -1,57 +1,304 @@
<?php <?php
class CommentModel {
class CommentModel
{
private $conn; private $conn;
public function __construct($conn) { public function __construct($conn)
{
$this->conn = $conn; $this->conn = $conn;
} }
public function getCommentsByTicketId($ticketId) { /**
$sql = "SELECT tc.*, u.display_name, u.username * Extract @mentions from comment text
FROM ticket_comments tc *
LEFT JOIN users u ON tc.user_id = u.user_id * @param string $text Comment text
WHERE tc.ticket_id = ? * @return array Array of mentioned usernames
ORDER BY tc.created_at DESC"; */
public function extractMentions($text)
{
$mentions = [];
// Match @username patterns (alphanumeric, underscores, hyphens)
if (preg_match_all('/@([a-zA-Z0-9_-]+)/', $text, $matches)) {
$mentions = array_unique($matches[1]);
}
return $mentions;
}
/**
* Get user IDs for mentioned usernames
*
* @param array $usernames Array of usernames
* @return array Array of user records with user_id, username, display_name
*/
public function getMentionedUsers($usernames)
{
if (empty($usernames)) {
return [];
}
$placeholders = str_repeat('?,', count($usernames) - 1) . '?';
$sql = "SELECT user_id, username, display_name FROM users WHERE username IN ($placeholders)";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId); // Changed to string since ticket_id is varchar
$types = str_repeat('s', count($usernames));
$stmt->bind_param($types, ...$usernames);
$stmt->execute(); $stmt->execute();
$result = $stmt->get_result(); $result = $stmt->get_result();
$comments = []; $users = [];
while ($row = $result->fetch_assoc()) {
$users[] = $row;
}
$stmt->close();
return $users;
}
/**
* Get total comment count for a ticket
*/
public function getCommentCount(int $ticketId): int
{
$stmt = $this->conn->prepare(
"SELECT COUNT(*) as total FROM ticket_comments WHERE ticket_id = ?"
);
$stmt->bind_param("i", $ticketId);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
return (int)($row['total'] ?? 0);
}
/**
* @param int $ticketId
* @param bool $threaded Build nested reply structure (threading)
* @param int $limit Max root-level comments to return (0 = all)
* @param int $offset Root-level comment offset for pagination
*/
public function getCommentsByTicketId($ticketId, $threaded = true, int $limit = 0, int $offset = 0)
{
$hasThreading = $this->hasThreadingSupport();
// When paginating with threading we fetch root comments page first,
// then pull all their replies in a second query.
if ($hasThreading && $threaded && $limit > 0) {
return $this->getThreadedCommentsPaged($ticketId, $limit, $offset);
}
if ($hasThreading) {
$sql = "SELECT tc.*, u.display_name, u.username, tc.parent_comment_id, tc.thread_depth
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.user_id
WHERE tc.ticket_id = ?
ORDER BY
CASE WHEN tc.parent_comment_id IS NULL THEN tc.created_at END DESC,
CASE WHEN tc.parent_comment_id IS NOT NULL THEN tc.created_at END ASC";
} else {
$sql = "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 = ?
ORDER BY tc.created_at DESC";
}
if ($limit > 0) {
$sql .= " LIMIT ? OFFSET ?";
}
$stmt = $this->conn->prepare($sql);
if ($limit > 0) {
$stmt->bind_param("iii", $ticketId, $limit, $offset);
} else {
$stmt->bind_param("i", $ticketId);
}
$stmt->execute();
$result = $stmt->get_result();
$commentMap = [];
while ($row = $result->fetch_assoc()) { while ($row = $result->fetch_assoc()) {
// Use display_name from users table if available, fallback to user_name field
if (!empty($row['display_name'])) { if (!empty($row['display_name'])) {
$row['display_name_formatted'] = $row['display_name']; $row['display_name_formatted'] = $row['display_name'];
} else { } else {
$row['display_name_formatted'] = $row['user_name'] ?? 'Unknown User'; $row['display_name_formatted'] = $row['user_name'] ?? 'Unknown User';
} }
$comments[] = $row; $row['replies'] = [];
$row['parent_comment_id'] = $row['parent_comment_id'] ?? null;
$row['thread_depth'] = $row['thread_depth'] ?? 0;
$commentMap[$row['comment_id']] = $row;
}
$stmt->close();
// Build threaded structure if threading is enabled (no pagination — all loaded)
if ($hasThreading && $threaded) {
$rootComments = [];
foreach ($commentMap as $id => $comment) {
if ($comment['parent_comment_id'] === null) {
$rootComments[] = $this->buildCommentThread($comment, $commentMap);
}
}
return $rootComments;
} }
return $comments; return array_values($commentMap);
} }
public function addComment($ticketId, $commentData, $userId = null) {
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled)
VALUES (?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql); /**
* Paginated threaded comments: fetch one page of root comments + all their replies.
*/
private function getThreadedCommentsPaged(int $ticketId, int $limit, int $offset): array
{
// Page of root comments
$rootSql = "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 IS NULL
ORDER BY tc.created_at DESC
LIMIT ? OFFSET ?";
$stmt = $this->conn->prepare($rootSql);
$stmt->bind_param("iii", $ticketId, $limit, $offset);
$stmt->execute();
$rootResult = $stmt->get_result();
$stmt->close();
$commentMap = [];
$rootIds = [];
while ($row = $rootResult->fetch_assoc()) {
$row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User');
$row['replies'] = [];
$row['parent_comment_id'] = null;
$row['thread_depth'] = 0;
$commentMap[$row['comment_id']] = $row;
$rootIds[] = $row['comment_id'];
}
if (empty($rootIds)) {
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();
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;
}
$rootComments = [];
foreach ($rootIds as $rid) {
if (isset($commentMap[$rid])) {
$rootComments[] = $this->buildCommentThread($commentMap[$rid], $commentMap);
}
}
return $rootComments;
}
/**
* Check if threading columns exist
*/
private function hasThreadingSupport()
{
static $hasSupport = null;
if ($hasSupport !== null) {
return $hasSupport;
}
$result = $this->conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'parent_comment_id'");
$hasSupport = ($result && $result->num_rows > 0);
return $hasSupport;
}
/**
* Recursively build comment thread
*/
private function buildCommentThread($comment, &$allComments)
{
$comment['replies'] = [];
foreach ($allComments as $c) {
if (
(int)$c['parent_comment_id'] === (int)$comment['comment_id']
&& isset($allComments[$c['comment_id']])
) {
$comment['replies'][] = $this->buildCommentThread($c, $allComments);
}
}
// Sort replies by date ascending
usort($comment['replies'], function ($a, $b) {
return strtotime($a['created_at']) - strtotime($b['created_at']);
});
return $comment;
}
/**
* Get flat list of comments (for backward compatibility)
*/
public function getCommentsByTicketIdFlat($ticketId)
{
return $this->getCommentsByTicketId($ticketId, false);
}
public function addComment($ticketId, $commentData, $userId = null)
{
// Check if threading is supported
$hasThreading = $this->hasThreadingSupport();
// Set default username (kept for backward compatibility) // Set default username (kept for backward compatibility)
$username = $commentData['user_name'] ?? 'User'; $username = $commentData['user_name'] ?? 'User';
$markdownEnabled = isset($commentData['markdown_enabled']) && $commentData['markdown_enabled'] ? 1 : 0; $markdownEnabled = isset($commentData['markdown_enabled']) && $commentData['markdown_enabled'] ? 1 : 0;
// Preserve line breaks in the comment text
$commentText = $commentData['comment_text']; $commentText = $commentData['comment_text'];
$parentCommentId = $commentData['parent_comment_id'] ?? null;
$threadDepth = 0;
$stmt->bind_param( // Calculate thread depth if replying to a comment
"sissi", if ($hasThreading && $parentCommentId) {
$ticketId, $parentComment = $this->getCommentById($parentCommentId);
$userId, if ($parentComment) {
$username, $threadDepth = min(($parentComment['thread_depth'] ?? 0) + 1, 3); // Max depth of 3
$commentText, }
$markdownEnabled }
);
if ($hasThreading) {
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled, parent_comment_id, thread_depth)
VALUES (?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param(
"sissiii",
$ticketId,
$userId,
$username,
$commentText,
$markdownEnabled,
$parentCommentId,
$threadDepth
);
} else {
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled)
VALUES (?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param(
"sissi",
$ticketId,
$userId,
$username,
$commentText,
$markdownEnabled
);
}
if ($stmt->execute()) { if ($stmt->execute()) {
$commentId = $this->conn->insert_id; $commentId = $this->conn->insert_id;
@@ -62,7 +309,9 @@ class CommentModel {
'user_name' => $username, 'user_name' => $username,
'created_at' => date('M d, Y H:i'), 'created_at' => date('M d, Y H:i'),
'markdown_enabled' => $markdownEnabled, 'markdown_enabled' => $markdownEnabled,
'comment_text' => $commentText 'comment_text' => $commentText,
'parent_comment_id' => $parentCommentId,
'thread_depth' => $threadDepth
]; ];
} else { } else {
return [ return [
@@ -71,5 +320,101 @@ class CommentModel {
]; ];
} }
} }
/**
* Get a single comment by ID
*/
public function getCommentById($commentId)
{
$sql = "SELECT tc.*, u.display_name, u.username
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.user_id
WHERE tc.comment_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $commentId);
$stmt->execute();
$result = $stmt->get_result();
return $result->fetch_assoc();
}
/**
* Update an existing comment
* Only the comment owner or an admin can update
*/
public function updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin = false)
{
// First check if user owns this comment or is admin
$comment = $this->getCommentById($commentId);
if (!$comment) {
return ['success' => false, 'error' => 'Comment not found'];
}
if ($comment['user_id'] !== (int)$userId && !$isAdmin) {
return ['success' => false, 'error' => 'You do not have permission to edit this comment'];
}
// Check if updated_at column exists
$hasUpdatedAt = false;
$colCheck = $this->conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'updated_at'");
if ($colCheck && $colCheck->num_rows > 0) {
$hasUpdatedAt = true;
}
if ($hasUpdatedAt) {
$sql = "UPDATE ticket_comments SET comment_text = ?, markdown_enabled = ?, updated_at = NOW() WHERE comment_id = ?";
} else {
$sql = "UPDATE ticket_comments SET comment_text = ?, markdown_enabled = ? WHERE comment_id = ?";
}
$stmt = $this->conn->prepare($sql);
$markdownInt = $markdownEnabled ? 1 : 0;
$stmt->bind_param("sii", $commentText, $markdownInt, $commentId);
if ($stmt->execute()) {
return [
'success' => true,
'comment_id' => $commentId,
'comment_text' => $commentText,
'markdown_enabled' => $markdownInt,
'updated_at' => $hasUpdatedAt ? date('M d, Y H:i') : null
];
} else {
return ['success' => false, 'error' => $this->conn->error];
}
}
/**
* Delete a comment
* Only the comment owner or an admin can delete
*/
public function deleteComment($commentId, $userId, $isAdmin = false)
{
// First check if user owns this comment or is admin
$comment = $this->getCommentById($commentId);
if (!$comment) {
return ['success' => false, 'error' => 'Comment not found'];
}
if ($comment['user_id'] !== (int)$userId && !$isAdmin) {
return ['success' => false, 'error' => 'You do not have permission to delete this comment'];
}
$ticketId = $comment['ticket_id'];
$sql = "DELETE FROM ticket_comments WHERE comment_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $commentId);
if ($stmt->execute()) {
return [
'success' => true,
'comment_id' => $commentId,
'ticket_id' => $ticketId
];
} else {
return ['success' => false, 'error' => $this->conn->error];
}
}
} }
?>

Some files were not shown because too many files have changed in this diff Show More