Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 11:42:21 -04:00
jared 99a0cf59a8 Merge development into main: Matrix-notification visibility-leak batch (#46, #69, #71, #72)
Lint / PHP (phpcs PSR-12) (push) Successful in 1m35s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 38s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 3m0s
Lint / Deploy (push) Successful in 2s
- Redact ticket title for non-public tickets in create/status-change Matrix notifications (#46)
- Exclude shared notify list and redact title in notifyWatchers() for non-public tickets (#71)
- Redact ticket title for non-public tickets in assignment notifications (#72)
- Verify mentioned-user access before sending @mention notifications (#69)
2026-09-11 11:26:58 -04:00
jaredandClaude Sonnet 5 5709c3134f Verify mentioned-user access before sending @mention notifications (#69)
Lint / PHP (phpcs PSR-12) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 16s
Lint / PHP requirements (version + extensions) (push) Successful in 47s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m31s
Lint / Deploy (push) Successful in 4s
sendMentionNotification(), called from add_comment.php, had no
visibility check at all — unlike sendCommentNotification()/
notifyWatchers() which redact the comment preview for non-public
tickets. Mentioning a user with zero standing access to a confidential
ticket (not creator/assignee/admin, not in visibility_groups) sent
them a Matrix DM with the full ticket title AND comment text — worse
than #46 since it's delivered directly to an individual rather than
diluted into a shared list.

add_comment.php now filters mentioned users through
canUserAccessTicket() before resolving Matrix IDs, skipping the
notification entirely for anyone without access (one of the two
options the issue names as acceptable). getMentionedUsers() needed to
start selecting is_admin and groups alongside user_id/username/
display_name, since canUserAccessTicket() requires them. Verified
against real MariaDB: a user mentioned on a confidential ticket they
don't own/aren't assigned to is correctly denied, a user in the
matching visibility_groups for an internal ticket is correctly
allowed, and the same user is correctly denied on a different internal
ticket whose group they're not in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 21:49:42 -04:00
jaredandClaude Sonnet 5 60bafae8a0 Redact ticket title for non-public tickets in assignment notifications (#72)
sendAssignmentNotification() had the same missing-visibility gap as
#46 in a separate function: assigning a user to a confidential/internal
ticket broadcast the ticket title to the shared Matrix notify list
unconditionally when MATRIX_NOTIFY_ASSIGNMENTS is enabled.

Threaded visibility through using the same redactedTitle() helper
added for #46, wired up from the already-fetched ticket row in
assign_ticket.php. The assignee is still DMed directly regardless,
since being assigned gives them standing access to the ticket — but
because notify_users is one shared payload, they see the same redacted
title as everyone else on it rather than a personalized one. Verified
end-to-end with a local HTTP server capturing the webhook payload for
both public and confidential tickets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 21:49:34 -04:00
jaredandClaude Sonnet 5 90d798966b Exclude shared notify list and redact title in notifyWatchers() for non-public tickets (#71)
notifyWatchers() only redacted the comment/activity preview for
non-public tickets — the shared MATRIX_NOTIFY_USERS list was still
merged into notify_users unconditionally, and the ticket title was
never redacted at all. A status-change/comment notification for a
confidential ticket with watchers still broadcast that ticket's title
to the shared list, even though the function's own docblock intended
to protect non-public tickets from it.

For non-public tickets, the shared list is now excluded entirely
(only actual watchers are notified) and the title is redacted via the
same redactedTitle() helper added for #46. Verified against real
MariaDB with a real watcher row: for a confidential ticket, the
captured webhook payload has only the watcher's Matrix ID (no shared
list) and a redacted title; for the same ticket made public, the
shared list is included and the title passes through unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 21:49:17 -04:00
jaredandClaude Sonnet 5 442cd1d6f6 Redact ticket title for non-public tickets in create/status-change Matrix notifications (#46)
sendTicketNotification() and sendStatusChangeNotification() always sent
the ticket title to the shared MATRIX_NOTIFY_USERS list regardless of
visibility, unlike sendCommentNotification()/notifyWatchers() which
already redact the comment/activity preview for non-public tickets.
Creating or changing the status of a confidential ticket broadcast its
title to a shared Matrix room, defeating the point of the Confidential
visibility level.

Added a shared redactedTitle() helper and threaded visibility through
both functions (sendTicketNotification reads it from the existing
$ticketData['visibility'] key; sendStatusChangeNotification takes a new
optional parameter, wired up in both callers from the already-fetched
ticket row). Verified end-to-end with a local HTTP server capturing the
actual webhook payloads: public tickets pass the title through
unchanged, confidential/internal tickets get the redacted placeholder.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 21:49:06 -04:00
jared 3664719148 Merge development into main: high-priority security/reliability batch (#27, #28, #30, #32)
Lint / PHP (phpcs PSR-12) (push) Successful in 29s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 25s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m44s
Lint / Deploy (push) Successful in 6s
- Add missing rate limiting to create_ticket_api.php (#27)
- Fix visibility-group matching disagreement between filter and access check (#28)
- Replace illusory transaction wrapping in migrate.php with statement-level resume (#30)
- Fix ticket_watchers.ticket_id type mismatch and missing FK (#32)
2026-09-08 21:33:33 -04:00
jaredandClaude Sonnet 5 d7940b1e31 Fix ticket_watchers.ticket_id type mismatch and missing FK (#32)
Lint / PHP (phpcs PSR-12) (push) Successful in 24s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 27s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m40s
Lint / Deploy (push) Successful in 2s
ticket_watchers.ticket_id was int(11) while every other satellite
table (ticket_comments, ticket_attachments, ticket_dependencies,
custom_field_values) uses varchar(9)/varchar(10) matching
tickets.ticket_id, and it had no FK constraint at all — unlike every
other satellite table — so orphaned watcher rows could never be
caught by referential integrity.

Changed the column to varchar(9) with an ON DELETE CASCADE FK to
tickets, in both 000_baseline.sql and a new idempotent
004_fix_ticket_watchers_type.sql (which also deletes any pre-existing
orphaned watcher rows before adding the constraint, since orphans
would otherwise make the ADD CONSTRAINT fail). Updated
watch_ticket.php, NotificationHelper::notifyWatchers(), and
notifications.php's audit-log JOIN to bind/compare ticket_id as a
string instead of casting to int, including replacing a fragile
CAST(entity_id AS UNSIGNED) with a direct string comparison.

Verified against real MariaDB: applied 004 against a simulated
pre-fix deployment with one valid and one orphaned watcher row —
the orphan is removed, the column converts losslessly, the FK is
added, and the migration is idempotent on re-run. Confirmed
ON DELETE CASCADE actually removes watchers when their ticket is
deleted, that inserting a watcher for a nonexistent ticket now fails
with a real FK violation, and exercised the updated watch/unwatch and
status-change-notification query paths end-to-end against the fixed
schema.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 21:28:42 -04:00
jaredandClaude Sonnet 5 fba251b85d Replace illusory transaction wrapping in migrate.php with statement-level resume (#30)
migrate.php wrapped each migration file's statements in
begin_transaction()/rollback(), but MySQL DDL statements cause an
implicit commit — so a rollback couldn't actually undo earlier DDL
already executed within the same file. A migration failing partway
left the DB altered but unrecorded, and the next run retried the
whole file from statement 1, hitting "already exists" errors not on
the safe-to-ignore allowlist and permanently wedging the runner.

Removed the transaction wrapper (it only gave false confidence) and
added a migration_progress table that records the index of the last
successfully-executed statement in each file. A re-run after a
partial failure now resumes right after the last success instead of
re-executing already-applied DDL. Verified against real MariaDB with
a 4-statement migration where statement 3 fails: run 1 correctly
applies statements 1-2 and records progress at index 1; after fixing
the bad statement, run 2 resumes at statement 3, completes, and
clears the progress marker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 21:28:32 -04:00
jaredandClaude Sonnet 5 fd777aa690 Fix visibility-group matching disagreement between filter and access check (#28)
getVisibilityFilter() (dashboard list/stats) matched via
FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', '')) — stripping
spaces from the column but not from the bound group name — while
canUserAccessTicket() (single-ticket access) did a plain trim with no
space-stripping at all. For a group name containing a space (e.g. "IT
Support"), a member could open an internal ticket directly by URL but
never see it in their dashboard list or stats counts.

Now strips spaces from the bound parameter too, matching the column-
side normalization, so both paths agree. Verified against real
MariaDB: a ticket visible via canUserAccessTicket() for a
space-containing group is now also matched by getVisibilityFilter()'s
SQL, a wrong-group user is denied by both, and the plain no-space case
is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 21:28:25 -04:00
jaredandClaude Sonnet 5 a9a39adcf8 Add missing rate limiting to create_ticket_api.php (#27)
Every other Bearer-key endpoint (ticket_status_api.php,
ticket_comment_api.php) calls RateLimitMiddleware::apply('api') before
opening a DB connection; create_ticket_api.php didn't, contradicting
README.md's claim that the whole Bearer API is rate-limited. A leaked
or guessed API key could hammer ticket creation unthrottled, each
insert also firing a Matrix webhook.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 21:28:18 -04:00
jared 23d94bfae7 Merge development into main: quick-win UX/perf batch (#76, #64, #99, #100)
Lint / PHP (phpcs PSR-12) (push) Successful in 37s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 35s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m24s
Lint / Deploy (push) Successful in 6s
- Consolidate dashboard clear-filters controls to one shared function (#76)
- Make SLA priority-alert banner update live on priority change (#64)
- Add HTTP Range/partial-content support to attachment downloads (#99)
- Paginate attachment listing (#100)
2026-09-08 21:18:14 -04:00
jaredandClaude Sonnet 5 e39b4f81ea Avoid insertAdjacentHTML flagged by semgrep in attachment pagination (#100)
Lint / PHP (phpcs PSR-12) (push) Successful in 46s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 33s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 4m0s
Lint / Deploy (push) Successful in 3s
The "load more attachments" append path used
grid.insertAdjacentHTML('beforeend', html), which the CI semgrep scan
flags as a blocking finding (detection of insertAdjacentHTML from a
non-constant string). The content was already fully escaped via
lt.escHtml() on every field, but switched to the same
temp-element + innerHTML + appendChild pattern used elsewhere to build
DOM from a generated HTML string, avoiding the flagged API without
changing behavior. Re-verified pagination append/remove behavior via
jsdom.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 21:05:43 -04:00
jaredandClaude Sonnet 5 3d5adbbfda Paginate attachment listing (#100)
Lint / PHP (phpcs PSR-12) (push) Successful in 36s
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP requirements (version + extensions) (push) Successful in 44s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Failing after 1m54s
Lint / Deploy (push) Successful in 2s
AttachmentModel::getAttachments() had no LIMIT/OFFSET, so a ticket
with hundreds of attachments loaded and rendered every one of them in
a single API response and DOM grid, unbounded.

Added optional limit/offset to getAttachments(), matching the pattern
already used by CommentModel::getCommentsByTicketId(). The GET handler
in upload_attachment.php now accepts limit/offset (default 40, capped
at 100) and returns total/has_more alongside the page of attachments.
ticket.js's loadAttachments()/renderAttachments() now fetch and append
pages, showing a "Load more attachments (N remaining)" control when
more are available. Verified against real MariaDB with 12 attachments
across 3 pages of 5: no duplicates or gaps across pages, and the
legacy unlimited call (getAttachments($ticketId) with no
limit/offset) still returns everything unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 20:57:22 -04:00
jaredandClaude Sonnet 5 b0765eb7f4 Add HTTP Range/partial-content support to attachment downloads (#99)
download_attachment.php always streamed the entire file regardless of
any Range request header, and never advertised Accept-Ranges. Large
video/PDF attachments couldn't be scrubbed in-browser, and an
interrupted download had to restart from byte 0.

Now parses a single-range "bytes=start-end" (including open-ended and
suffix forms) request header and responds with 206 Partial Content and
a Content-Range header, seeking the file handle to the requested
offset; out-of-range requests get 416 with Content-Range: bytes
*/<size>. Verified against a real file served over a local PHP dev
server with curl for exact-range, open-ended, suffix, no-Range, and
out-of-bounds cases, confirming byte-identical output for each.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 20:57:16 -04:00
jaredandClaude Sonnet 5 d84d8fae58 Make SLA priority-alert banner update live on priority change (#64)
The P1/P2 SLA breach banner and progress bar were rendered server-side
at page load and never touched again. Changing a ticket's priority in
edit mode (P1->P3 or P3->P1) left the banner in a stale state —
showing/counting for a priority that no longer applied — until the
page was reloaded.

Moved the banner's render/update/teardown logic into a reusable
renderSlaBanner() in ticket.js (verified via jsdom against real
DOM: creates the banner for P1/P2, removes it when priority drops
below P2 or the ticket is closed, and re-creates it including the
already-breached state when priority is raised into P1/P2 range).
The priority-change handler now calls it after a successful update,
and the initial page load calls it once instead of relying on
duplicated server-rendered markup + inline script.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 20:57:10 -04:00
jaredandClaude Sonnet 5 b6d3cc4e70 Consolidate dashboard clear-filters controls to one shared function (#76)
The sidebar's own Clear button cleared status/category/type/dates but
never search/priority/assigned_to, while the page-level "Clear All
Filters" button cleared a different subset. Neither control alone
reliably returned the dashboard to a fully unfiltered state. The
sidebar button now delegates to clearAllFilters() so both controls
always clear the same complete set of params.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 20:57:03 -04:00
jaredandClaude Sonnet 5 cef1689c05 Merge development into main: 15-issue triage + fix batch
Lint / PHP (phpcs PSR-12) (push) Successful in 23s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 24s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m55s
Lint / Deploy (push) Successful in 4s
Fixes 15 tracked issues from the tinker_tickets tracker, all verified
against a local MariaDB instance and/or jsdom/manual test harnesses
where applicable: #75, #84, #102, #29, #53, #90, #60, #79, #61, #31,
#96, #41, #89, #59, #52, #42, #66, #40, #106, #54, #92, #97, #51, #91,
#101, #63, #55, #65, #107.

Also fixes a real, previously-undetected outage in .env.example:
parse_ini_file() could not parse the file as shipped (fragile '#'
comment handling plus an unquoted LDAP_BIND_DN value), meaning the
documented setup step of `cp .env.example .env` would have broken
every fresh deployment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 15:39:36 -04:00
jaredandClaude Sonnet 5 3cca956ee7 Preserve native undo/redo in markdown toolbar buttons (#107)
Lint / PHP (phpcs PSR-12) (push) Successful in 23s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 29s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m32s
Lint / Deploy (push) Successful in 2s
insertMarkdownFormat, insertMarkdownText, toolbarList, toolbarHeading,
and toolbarQuote all set textarea.value = ... directly. Assigning
.value programmatically discards the browser's entire native undo
stack (vs. document.execCommand('insertText', ...), which preserves
it) — e.g. type a paragraph, click Bold, then Ctrl+Z undid the whole
paragraph instead of just the bold markup.

Added insertTextPreservingUndo(), which selects the exact range being
replaced and routes through execCommand('insertText', ...) — the same
mechanism real typing uses — falling back to the old direct assignment
(losing undo, matching prior behavior) only if execCommand is
unavailable or unsuccessful.

Verified with a jsdom harness that the fallback path (jsdom doesn't
implement execCommand, since native undo is a real-browser-only
feature untestable via jsdom) produces byte-identical resulting text
and cursor positions to the original implementation across all 5
toolbar functions, for both selected and cursor-only cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 14:21:39 -04:00
jaredandClaude Sonnet 5 818af137f3 Add touch-event fallback to lt.sortable for kanban drag-and-drop (#65)
lt.sortable only wired dragstart/dragend/dragover/drop — the native
HTML5 Drag-and-Drop API. iOS Safari doesn't implement HTML5 DnD on
arbitrary elements at all, and mobile Chrome's support is poor, so
kanban card status-drag was effectively unusable via touch, despite
README's "Touch-friendly controls" claim.

Added touchstart/touchmove/touchend/touchcancel handling that mirrors
the existing mouse-based behavior: a small movement threshold (8px)
distinguishes a tap/scroll from drag intent, the dragged card is
repositioned via fixed positioning to follow the finger (reparented to
document.body to avoid clipping by an overflow:hidden ancestor), and
elementFromPoint resolves the hover target for the same
placeholder-insertion logic dragover already uses, including
cross-column moves via the shared group check.

touchmove/touchend/touchcancel are registered on document rather than
the sortable list itself: since touch events keep targeting their
touchstart element for the whole gesture regardless of DOM mutations,
and the dragged item gets reparented to document.body mid-drag, a
listener on the original list would stop receiving bubbled events
once that reparenting happens.

lt.sortable lives in base.js, the shared web_template copy used by
other LotusGuild apps (per its own header comment) — this fix should
be contributed upstream too, not just kept local to this repo.

Verified with a jsdom harness (stubbing getBoundingClientRect and
elementFromPoint against known layouts) covering: sub-threshold
movement not starting a drag, same-column reorder, cross-column move
with correct final DOM parent and order, and touchcancel cleanly
resetting state. This caught a real bug during development — an
earlier version listened on the list element for touchmove/touchend,
which silently stopped receiving events after the drag-start
reparenting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 14:18:54 -04:00
jaredandClaude Sonnet 5 338bed7eb7 Add per-ticket attachment count/storage quota (#55)
api/upload_attachment.php enforced a per-file size cap but nothing
bounded the total number of attachments on a single ticket or their
cumulative size over time — an authenticated low-privilege user could
slowly fill the uploads/ disk by attaching many files across tickets,
bounded only by the general rate limiter (which throttles request
rate, not storage volume).

Added MAX_ATTACHMENTS_PER_TICKET (50) and
MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET (100MB) config defaults, enforced
before move_uploaded_file() using AttachmentModel::getAttachmentCount()
and getTotalSizeForTicket() — both already existed in the model with
zero callers, apparently added for exactly this purpose but never
wired in.

Verified against a local MariaDB instance: with 3 existing 1MB
attachments and a 3-attachment cap, the count check correctly rejects
a 4th; with a 5MB total cap, a 2.5MB upload that would push the ticket
over the limit is correctly rejected while a small one that fits is
not.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 14:14:00 -04:00
jaredandClaude Sonnet 5 67d3c13bb6 Fix .env.example: missing Matrix vars, plus a real parse_ini_file outage (#63)
Primary fix (#63): added the 5 env vars config.php reads and README
documents but .env.example never listed: MATRIX_DOMAIN,
SYNAPSE_ADMIN_URL, SYNAPSE_ADMIN_TOKEN, MATRIX_NOTIFY_COMMENTS, and
MATRIX_NOTIFY_ASSIGNMENTS. A deployer following only .env.example had
no indication these existed, silently missing watcher Matrix DMs and
comment/assignment notifications.

While verifying the fix by actually running .env.example through
parse_ini_file() (what config.php calls), found this file could not
be parsed at all — a real, currently-live outage for anyone following
its own first-line instruction ("Copy this file to .env and fill in
your values"):

1. PHP's ini parser treats "#" comments as fragile: punctuation like
   parentheses or quotes inside a "#" comment can throw a syntax error
   even though the line is meant to be inert. The file's header
   comment itself (and 15+ other comment lines) tripped this. Switched
   every comment to ";", which parse_ini_file treats as a true inert
   comment regardless of content — verified with isolated repros of
   both prefixes under all three INI_SCANNER_* modes.
2. LDAP_BIND_DN's example value contained unquoted "=" and commas,
   violating the file's own documented quoting rule and causing a
   second, independent parse failure. Quoted it (and the two other
   comma-bearing LDAP DN values) to match the rule.

config.php has zero fallback for a parse failure — it die()s
immediately — so either bug alone would have taken down every fresh
deployment that didn't hand-edit the example file's comments first.

Verified end-to-end: copied .env.example to a real .env file
unmodified and ran it through config.php's exact parse_ini_file +
quote-stripping logic; it now parses cleanly with all 23 keys
(including the 5 new ones) and LDAP_BIND_DN resolves to the correct
unquoted DN string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 14:12:04 -04:00
jaredandClaude Sonnet 5 e4c240009d Dashboard cleanup: chart empty-state message, dead click-handler (#101)
Two small findings from the same dashboard audit pass:

1. Charts rendered a bare empty frame with no message when the
   filtered dataset was empty (fresh install, or a non-admin's
   visibility-filtered ticket set happening to be zero). makeDonut/
   makeBar now show a "No data for current filters" message in the
   chart's place instead of silently doing nothing.

2. Stat cards had two independent, redundant click-handler
   implementations. lt.statsFilter.init() (base.js, shared web_template
   code) read each card's data-filter-key/data-filter-val attributes
   and called window.lt_onStatFilter(key, val) on click — but that
   global is never defined anywhere in this app, so it only toggled a
   cosmetic .active class with no functional effect. The actual
   navigation logic is the separate handler at ~line 1282 that ignores
   those attributes entirely. Both fired on the same click with no
   visible symptom, but the markup looked load-bearing and wasn't — a
   trap for a future edit that touches one implementation assuming
   it's the only one. Removed the dead lt.statsFilter.init() call and
   the now-unused data-filter-key/data-filter-val attributes from this
   app's DashboardView.php (left the shared lt.statsFilter module in
   base.js itself untouched, since other LotusGuild apps consuming the
   same shared template file may define their own lt_onStatFilter).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 12:27:26 -04:00
jaredandClaude Sonnet 5 6553c0227d Fix dogpile cache-overwrite race in CacheHelper::remember() (#91)
remember() had no protection against a slow cache-miss recomputation
overwriting a fresher write. If Request A started computing stats just
before a ticket mutation + invalidateCache(), and Request B started
just after (correctly computing fresh, post-mutation data), A could
finish (using stale pre-mutation data) after B and overwrite B's fresh
cache entry — extending staleness by up to another full TTL.

Added a per-prefix invalidation epoch: delete() bumps it, and
remember() snapshots it before running the callback and only writes
if the epoch hasn't changed since — otherwise a newer invalidation
happened mid-computation and the result being written is already
stale, so it's dropped (the caller still gets its own result; only the
cache write is skipped).

Verified with two real concurrent PHP processes racing against the
same cache key (a slow "Request A" callback vs. a fast "Request B"
that invalidates then recomputes): the cache ends up holding B's fresh
value, not A's late stale overwrite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 12:25:04 -04:00
jaredandClaude Sonnet 5 4fade1a9d3 Reject the semantic inverse of an existing ticket dependency (#51)
addDependency()'s "already exists" check only matched the exact
(ticket_id, depends_on_id, dependency_type) tuple. A user could add
"A blocks B" from ticket A's page, then separately add "B blocked_by
A" from ticket B's page — wouldCreateCycle() correctly found no cycle
(both normalize to the same precedence edge), so the insert was
allowed, creating two DB rows describing one real relationship (shown
twice on ticket B's page: once under Dependencies, once under
Dependents).

Added an inverse-relationship check before the insert: blocks/
blocked_by are inverses of each other, relates_to is its own inverse
(symmetric). duplicates has no defined inverse type in the schema, so
both directions remain independently insertable, which is correct —
"A duplicates B" and "B duplicates A" are distinct claims.

Verified against a local MariaDB instance: the exact repro from the
issue (A blocks B, then B blocked_by A) is now rejected, relates_to's
symmetric case is rejected in both directions, and duplicates in
either direction is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 12:23:04 -04:00
jaredandClaude Sonnet 5 3f1e06479d Strip EXIF/GPS metadata from image uploads (#97)
api/upload_attachment.php did a raw move_uploaded_file() with zero
image processing. A photo attached from a phone retained embedded
EXIF, including GPS coordinates, and download_attachment.php streams
the file byte-for-byte back to any user with ticket visibility — for
an infrastructure company, this could leak a data center or office's
precise physical location through a routine ticket photo, especially
on Confidential-visibility tickets whose whole point is restricting
exactly this kind of detail.

Added stripImageMetadata(): decodes and re-encodes JPEG/PNG/GIF/WebP
uploads via GD, which drops EXIF chunks that aren't part of the pixel
data. Best-effort — leaves the file untouched on any failure (corrupt
image, unsupported format, GD unavailable, or an oversized decoded
pixel count guarding against a decompression-bomb-style crafted image)
rather than blocking the upload.

Verified with a real GPS-tagged JPEG (generated via piexif) and a
GD/PHP harness: GPS EXIF is gone after stripping, the image stays
valid and correctly sized, PNG alpha transparency is preserved, and
corrupt files / non-image MIME types are left byte-for-byte unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 12:22:02 -04:00
jaredandClaude Sonnet 5 caeb9269d9 README: add missing StatsModel::invalidateCache() caller (#92)
Lint / PHP (phpcs PSR-12) (push) Successful in 38s
Lint / JS (eslint) (push) Successful in 15s
Lint / PHP requirements (version + extensions) (push) Successful in 56s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m25s
Lint / Deploy (push) Successful in 5s
Dev Note #24 listed 7 callers; api/ticket_status_api.php (the Bearer
API's status-change endpoint) also correctly calls invalidateCache()
but wasn't in the list. Behavior was already correct — this is a pure
documentation completeness fix so the caller list stays an accurate
reference for future maintainers deciding whether a new mutating path
needs the same call.

Verified via grep -rl "invalidateCache" that these 8 files (plus
StatsModel.php itself, and an unrelated same-named method on
UserModel) are the complete set of real callers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 11:59:23 -04:00
jaredandClaude Sonnet 5 70ef42c311 Delete dead helpers/OutputHelper.php (#54)
Zero callers anywhere in the app — confirmed via grep for
"OutputHelper::" across the whole codebase. Every view actually calls
htmlspecialchars() directly instead, which a prior audit confirmed is
done consistently, so escaping was never actually at risk. This was
just a misleading, unused class that README.md's file reference
implied was part of the app's active XSS-prevention story. Removed the
file and its README Project Structure entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 11:58:58 -04:00
jaredandClaude Sonnet 5 1e972fe7dc Add memory_limit/max_execution_time sanity checks (#106)
config/requirements.php only checked PHP version and 6 extensions. A
deployment on a host with a low default memory_limit (e.g. shared-
hosting-style 128M) passed the startup requirements check cleanly and
only surfaced as a mysterious failure under real load — a large CSV
export, an oversized dashboard query on a big install.

Added min_memory_limit_mb (256) and min_max_execution_time (30s)
thresholds to config/requirements.php, checked as warnings (not hard
failures, since a low limit doesn't break every request) in both
scripts/check_requirements.php (CI) and api/health.php (production
monitoring). -1/0 (unlimited) always passes.

Verified the ini-size parsing and warning logic directly with
low/high/unlimited memory_limit and max_execution_time values.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 11:58:20 -04:00
jaredandClaude Sonnet 5 700048337f Fix inconsistent FK ON DELETE behavior on bulk_operations/ticket_templates (#40)
bulk_operations.performed_by and ticket_templates.created_by had no
ON DELETE clause (defaulting to RESTRICT), unlike every other
user-reference FK in the schema (tickets.*, ticket_attachments,
ticket_dependencies, recurring_tickets, api_keys), which all use
SET NULL. Deleting a user who ever ran a bulk operation or created a
template hard-failed at the DB level instead of nulling the
reference, breaking the pattern used everywhere else.

performed_by was NOT NULL, so it had to become nullable to support
SET NULL, matching how every other SET NULL column is defined.

- Fixed 000_baseline.sql for fresh installs.
- Added 003_fk_on_delete_set_null.sql for existing deployments.

Verified against a local MariaDB instance: reproduced the old RESTRICT
schema, ran the migration (twice, for idempotency), then confirmed
deleting a user with rows in both tables now nulls the references
instead of failing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 11:56:33 -04:00
jaredandClaude Sonnet 5 3221ccfd29 Batch the audit log retention DELETE to bound lock hold time (#66)
deleteOldLogs() ran a single unbounded DELETE. created_at is indexed
so row selection itself is cheap, but on a large qualifying set (first
run after enabling/changing AUDIT_LOG_RETENTION_DAYS, or after the
cron silently missed runs) an unbounded single-statement DELETE holds
row locks for the full duration — risking contention with the frequent
concurrent INSERTs the audit log receives from live traffic. Now
deletes in batches of 1000 (parameterized), looping until nothing
qualifies.

Verified against a local MariaDB instance with a batch size of 10
forcing multiple loop iterations: deleted exactly the stale rows,
left recent rows untouched, correct total count returned.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 11:53:42 -04:00
jaredandClaude Sonnet 5 0d6b08f5d2 Cap get_users.php result set as defense-in-depth (#42)
api/get_users.php returned every user's user_id/username/display_name
to any authenticated session with no pagination or limit — needed for
mention/assignment typeahead, but a blanket enumeration a compromised
low-privilege session could scrape in one call. Added a LIMIT 500;
every caller already only uses this for typeahead/dropdown filtering,
never a literal full roster, so this doesn't change behavior for any
real deployment size while bounding the response.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 11:53:36 -04:00
jaredandClaude Sonnet 5 92aea89b74 User Activity report: filter 'Last Activity' by the selected date range (#52)
The last_activity subquery had no WHERE clause on the report's
date-range filter, so it always showed true all-time last activity
even when the page was filtered to e.g. "last 7 days" — inconsistent
with every other column on the same report. Added the same
DATE(created_at) BETWEEN ? AND ? clause used by the report's other
subqueries.

Verified against a local MariaDB instance: an out-of-range audit_log
row is correctly excluded from last_activity once the filter is
applied.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 11:53:31 -04:00
jaredandClaude Sonnet 5 f59b3d529b Notification bell: pause polling when tab hidden, backoff on failure (#59)
setInterval(loadNotifications, 60000) ran unconditionally regardless
of tab visibility, and failures retried at the same fixed 60s cadence
forever. Now skips polling while document.hidden, resumes immediately
via visibilitychange when the tab regains focus, and backs off
exponentially (capped at 5 min) on repeated fetch failures, resetting
to the normal 60s cadence on the next success.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 11:53:25 -04:00
jaredandClaude Sonnet 5 c892d9dcc8 Recompute next_run_at when re-enabling a paused recurring schedule (#89)
toggleActive() flipped is_active without touching next_run_at. If a
schedule was disabled while next_run_at was still in the future, then
re-enabled after that date had passed, the next cron tick saw
next_run_at <= NOW() and fired immediately — surprising for an admin
expecting a re-enabled "daily" schedule to wait until its next natural
occurrence. Now recomputes next_run_at from the current time when
transitioning to active, matching what a fresh schedule creation would
produce; disabling is unchanged.

Verified against a local MariaDB instance: re-enabling a schedule
whose next_run_at was in 2020 recomputed it to tomorrow at the
scheduled time; disabling leaves next_run_at untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 11:45:11 -04:00
jaredandClaude Sonnet 5 5e8af39563 Fix collation inconsistency on saved_filters/ticket_attachments (#41)
README Dev Note #12 mandates utf8mb4_general_ci for new tables, but
these two tables were created with utf8mb4_unicode_ci in the baseline
schema — inconsistent with every other table, and a future join or
comparison against a general_ci column would need explicit COLLATE
casts or hit "Illegal mix of collations" errors.

- Fixed 000_baseline.sql so a fresh install matches the convention
  directly.
- Added 002_fix_collation_consistency.sql for existing deployments.
  MariaDB silently drops the inline CHECK (json_valid(...)) constraint
  on saved_filters.filter_criteria when that column is MODIFYed (found
  by actually running this against a local MariaDB instance), so the
  migration explicitly re-adds it after the collation conversion.

Verified against a local MariaDB 10.11: baseline applies cleanly,
migration is idempotent (safe to run twice), and the json_valid CHECK
is still enforced afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 11:43:53 -04:00
jaredandClaude Sonnet 5 7c1c1b61cc Fix race in first-time login user creation (#96)
syncUserFromAuthelia() did a plain check-then-insert with no
transaction, so two simultaneous first-visit requests for the same
brand-new user (e.g. two tabs opened right after SSO login) could
race: the second INSERT hits users.username's UNIQUE KEY, which
mysqli throws on (uncaught, PHP 8.1+ default report mode) rather than
returning false. Switched to INSERT ... ON DUPLICATE KEY UPDATE
followed by a re-fetch by username, so the losing request updates the
winner's row instead of throwing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 11:41:30 -04:00
jaredandClaude Sonnet 5 6eefeafcbf Fix avatar color drift between PHP and JS (#31)
The JS side claimed to "mirror the PHP crc32 % 4 logic" but actually
implemented a different rolling hash (classic String.hashCode()-style),
so the same display name could get different avatar colors depending
on whether a comment was server-rendered or client-rendered (new
comment, reply, watcher avatars, "Load more" pagination).

Added a real CRC-32 (IEEE 802.3/zlib polynomial, UTF-8 byte sequence)
to ticket.js and switched all three JS call sites (avatarColorClass,
watcher avatars, and buildCommentEl in TicketView.php) to use it,
verified to produce identical output to PHP's crc32() including for
non-ASCII names.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 11:40:43 -04:00
jaredandClaude Sonnet 5 e0c7399998 Advanced Search: swap inverted date/priority ranges instead of submitting them (#61)
A user could set an end date before a start date, or priority_min >
priority_max, and the filter would be silently sent as an
unsatisfiable range with zero results and no explanation. Now swaps
min/max (and from/to) before building the query string when they're
in the wrong order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 10:54:56 -04:00
jaredandClaude Sonnet 5 4d0dc2c2ce Remove dead sortTable() and write-only ticketViewMode key (#79)
Two small dead-code cleanups from a dashboard.js state-management
audit:
- sortTable(table, column) had zero callers — actual table sorting is
  wired through lt.sortTable.init() via initTableSorting().
- setViewMode() wrote localStorage['ticketViewMode'], but nothing ever
  read it back; the real view-mode restoration on page load reads
  lt_activeTab_<path>, written separately by lt.tabs in base.js.

Both looked load-bearing but weren't, risking a future edit assuming
otherwise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 10:54:51 -04:00
jaredandClaude Sonnet 5 e9494dd4b3 Use server-verified mime_type for attachment thumbnail detection (#60)
renderAttachments() decided whether to render an image thumbnail by
regex-matching the display filename extension, rather than the
finfo-verified mime_type the API already returns. A file whose real
type differs from its display name (e.g. a PDF a user named
photo.png) rendered a broken <img> instead of falling back to the
file-type icon.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 10:54:45 -04:00
jaredandClaude Sonnet 5 33de91cc86 Delete dead RecurringTicketModel::updateAfterRun() (#90)
Zero callers anywhere in the codebase — superseded by claimForRun(),
which the cron script actually uses and which additionally guards
against the double-fire race between concurrent cron invocations that
this method lacked. Removing it so a future reuse doesn't silently
reintroduce that race.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 10:44:19 -04:00
jaredandClaude Sonnet 5 f7872b0980 Use showConfirmModal() instead of browser confirm() for template overwrite (#53)
CreateTicketView.php was the one remaining spot using the native
confirm() dialog, violating README Dev Note #21. Split loadTemplate()
into a confirm check + applyTemplate(), routed through the project's
styled showConfirmModal(), matching every other destructive-action
confirmation in the app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 10:44:15 -04:00
jaredandClaude Sonnet 5 2bda603647 Chart click-to-filter now merges into the current query string (#29)
gotoFilter() built a brand-new URLSearchParams containing only the
clicked chart segment's filter keys, discarding every other active
filter (search text, date range, saved-filter selection, etc.) on
navigation. Now merges the segment's filter into the current
location.search, same fix approach already applied to #22.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 10:44:10 -04:00
jaredandClaude Sonnet 5 1ab4d01a3a Fix broken Quick Assign dropdown (#102)
quickAssign() wired lt.combobox.init() with an onSelect callback, but
combobox only supports the multi-select onChange(selected[]) contract
— onSelect is never invoked, so _quickAssignUserId stayed undefined no
matter what the user picked and Quick Assign always showed "Please
select a user from the list." Switched to lt.typeahead.init(), which
does support onSelect, matching the already-working Bulk Assign modal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 10:41:47 -04:00
jaredandClaude Sonnet 5 6183bcd421 Notification titles: handle non-status ticket edits (#84)
The 'update' notification formatter unconditionally read
details['status']['from']/['to'], so any title/priority/description/
category/type/visibility-only edit fell through to '?' on both sides
and produced a broken "changed status on #123: ? → ?" title regardless
of what actually changed. Now it branches on the delta shape actually
present: the flat {field, from, to} shape used for visibility changes,
then each per-field {from, to} delta in priority order, falling back
to a generic "updated ticket" message only if none match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 10:41:29 -04:00
jaredandClaude Sonnet 5 1617dc5442 Fix 'Clear All Filters' to clear the real date-range params (#75)
clearAllFilters() deleted the nonexistent date_from/date_to query
params. Every actual date filter (sidebar, Advanced Search, saved
filters, stat-card links) uses created_from/to, updated_from/to, and
closed_from/to, so clicking the button silently left any active date
range in place.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 10:41:24 -04:00
jaredandClaude Sonnet 5 6e0863449f Trim comment text before persisting, not just for validation
Security / PHP Security (semgrep) (push) Successful in 2m6s
Lint / Deploy (push) Successful in 3s
Lint / PHP (phpcs PSR-12) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 11s
Lint / PHP requirements (version + extensions) (push) Successful in 29s
Lint / Notify on failure (push) Skipped
add_comment.php computed a trimmed copy of comment_text only to check
for empty input, then passed the original untrimmed $data through to
CommentModel::addComment(), so any leading/trailing whitespace the
user typed (or pasted) was written to ticket_comments.comment_text as-is.
update_comment.php already trims before saving edits, so a comment
could pass through this endpoint once with untrimmed text (creation)
and be silently corrected the moment it was next edited — inconsistent
storage that, combined with the markdown parser's line-anchored regexes
(headings, tables, lists all match on ^), could make a markdown-enabled
comment mis-render after a reload depending on whether its first line
carried leading whitespace.

Also trims in the "Load more comments" pagination re-render path in
TicketView.php, matching the two on-load renderers in markdown.js so
all three code paths that call parseMarkdown() on stored comment text
treat leading whitespace consistently.

Closes #18

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 21:03:00 -04:00
jared 1fb984e352 Merge #22 chart click-to-filter into main
Lint / JS (eslint) (push) Successful in 19s
Lint / PHP requirements (version + extensions) (push) Successful in 56s
Lint / PHP (phpcs PSR-12) (push) Successful in 30s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m3s
Lint / Deploy (push) Successful in 19s
2026-08-07 23:15:59 -04:00
jared ce0ea66994 Charts: click a segment to filter the dashboard (#22)
Lint / PHP (phpcs PSR-12) (push) Successful in 31s
Lint / JS (eslint) (push) Successful in 21s
Lint / PHP requirements (version + extensions) (push) Successful in 1m7s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Successful in 3s
Security / PHP Security (semgrep) (push) Successful in 2m0s
All three charts (priority donut, status donut, category bar) now navigate to
the same URL filters the stat cards already use, with a pointer cursor on
hover, a title hint, and "click to filter" in the tooltip.

The status each click applies is explicit rather than left to the default. With
no `status` param the controller falls back to the viewer's
default_status_filters preference, which can be anything, so the list would not
necessarily match what the chart counted. StatsModel builds by_priority and
by_category with `status != 'Closed'` while by_status spans every status, so
only the priority and category charts pin the open set; the status chart filters
on the clicked status alone (which is how clicking "Closed" works at all).

Verified two ways:
- 17/17 in headless chromium, driving the real chart script from this view with
  the Chart constructor stubbed, asserting the exact query each click produces
  and that a click hitting no segment navigates nowhere.
- Against the live database, every segment's count equals the number of tickets
  its filter returns — 12/12 across all three charts — so the list you land on
  matches the number you clicked.
2026-08-07 23:15:51 -04:00
jared 4fd2c7ce7d Merge #20 modal dismissal fix into main
Lint / PHP (phpcs PSR-12) (push) Successful in 18s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 39s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m32s
Lint / Deploy (push) Successful in 3s
2026-08-07 23:07:14 -04:00
jared 2ff7345a73 Dismissing the required-comment modal no longer looks like a close (#20)
Lint / JS (eslint) (push) Successful in 15s
Lint / PHP requirements (version + extensions) (push) Successful in 41s
Security / PHP Security (semgrep) (push) Successful in 1m8s
Lint / PHP (phpcs PSR-12) (push) Successful in 18s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Successful in 2s
A modal can be dismissed four ways: the ✕ button, Cancel, a backdrop click, or
Escape. base.js handles the last two globally (a document click handler and
registerKey('escape', closeAllModals)), so the status-change modal — which wired
only the two buttons — never learned it had been dismissed. The status dropdown
kept displaying the new status even though update_ticket.php was never called,
so the ticket looked closed with no comment until a reload showed it still open.

The same gap left every dynamically-inserted modal in the DOM when dismissed
that way, so the next open inserted a duplicate id that shadowed the live one.

- base.js closeModal now dispatches a bubbling lt:modalclose event (synced to
  web_template as bbec859), and _statusCommentModal treats it as "no comment".
- ticket.js reverts the dropdown on any dismissal, guarded against the re-entry
  its own lt.modal.close() would otherwise cause.
- dashboard.js gains openModalWithDismiss() so all seven dynamic modals plus the
  generic prompt modal tear down however they are dismissed.

Verified in headless chromium against all four dismissal routes plus a
confirm-with-comment control: 22/22. Against the pre-fix files the same test
fails 6 assertions — backdrop and Escape leave the dropdown on "Closed *" with
an orphaned overlay — so it reproduces the reported behaviour exactly.
2026-08-07 23:07:06 -04:00
jared 1de04d4908 Clear the markdown live preview after posting a comment
Setting the textarea's .value programmatically does not fire an 'input' event,
so updatePreview() never ran and the preview kept showing the just-posted
comment's rendered markdown underneath an empty composer.

(This change was already present in the working tree at the start of the
session; committing it on its own rather than folding it into an unrelated fix.)
2026-08-07 23:06:36 -04:00
66 changed files with 2289 additions and 776 deletions
+69 -37
View File
@@ -1,60 +1,92 @@
# Tinker Tickets Environment Configuration
# Copy this file to .env and fill in your values
#
# NOTE: This file is parsed with parse_ini_file(). Any value containing special
# characters (#, ;, =, quotes, spaces, etc.) MUST be wrapped in double quotes,
# e.g. DB_PASS="p@ss;word#1". The application now fails loudly (dies with a clear
# error) if the .env file cannot be parsed, so an unquoted special character will
# take the whole app down rather than silently using a wrong value.
; Tinker Tickets Environment Configuration
; Copy this file to .env and fill in your values
;
; NOTE: This file is parsed with PHP's parse_ini_file. Any value containing
; special characters -- #, ;, =, quotes, spaces, etc. -- MUST be wrapped in
; double quotes, e.g. DB_PASS="p@ss;word#1". The application now fails loudly
; -- dies with a clear error -- if the .env file cannot be parsed, so an
; unquoted special character will take the whole app down rather than
; silently using a wrong value.
;
; Comments in this file use ";" rather than "#": PHP's ini parser treats "#"
; comments as fragile -- punctuation like parentheses or quotes inside a "#"
; comment can produce a syntax error even though the line is meant to be
; inert, silently breaking every value below it. ";" comments don't have this
; problem, so keep using ";" for any comment added to this file.
# Database Configuration
; 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 (optional - for notifications via matrix-hookshot)
; Set to your hookshot generic webhook URL, e.g.:
; https://matrix.lotusguild.org/webhook/uuid-goes-here
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 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)
; Matrix homeserver domain (used to build Matrix user IDs from LLDAP usernames)
MATRIX_DOMAIN=
; Synapse internal URL and admin token (used to resolve usernames -> Matrix IDs
; for watcher DMs)
SYNAPSE_ADMIN_URL=
SYNAPSE_ADMIN_TOKEN=
; Optional: send a Matrix notification on comments and/or assignments (0/1)
MATRIX_NOTIFY_COMMENTS=0
MATRIX_NOTIFY_ASSIGNMENTS=0
; 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 for HTTP_HOST validation (comma-separated)
; Include all domains that can access this application
ALLOWED_HOSTS=localhost,127.0.0.1
# Trusted reverse proxy IP(s), comma-separated (e.g. the Authelia/nginx proxy).
# Set this to the IP address(es) of your reverse proxy. Authelia forward-auth
# headers (Remote-User / Remote-Groups) and forwarded client IPs are only
# trusted when REMOTE_ADDR is in this list.
#
# Leaving this EMPTY disables reverse-proxy verification entirely: the app then
# trusts Remote-User / Remote-Groups headers from ANY source. That is unsafe if
# the PHP backend is reachable directly (bypassing the proxy), because a client
# can then spoof those headers and log in as an admin. Only leave it empty when
# network topology guarantees PHP is reachable solely via the trusted proxy.
#
# Exact IP match only (no CIDR). Example (single proxy): TRUSTED_PROXIES=10.10.10.27
# Example (multiple): TRUSTED_PROXIES=10.10.10.27,10.10.10.28
; ============================================================================
; REQUIRED FOR PRODUCTION -- READ BEFORE DEPLOYING -- TRUSTED_PROXIES
; ============================================================================
; Trusted reverse proxy IPs, comma-separated -- e.g. the Authelia/nginx proxy.
; Set this to the IP address(es) of your reverse proxy. Authelia forward-auth
; headers (Remote-User / Remote-Groups) and forwarded client IPs are only
; trusted when REMOTE_ADDR is in this list.
;
; Leaving this EMPTY disables reverse-proxy verification entirely: the app then
; trusts Remote-User / Remote-Groups headers from ANY source. If the PHP
; backend is reachable directly -- a misconfigured firewall rule, a container
; network accidentally exposing the port, SSRF from another internal service
; -- ANYONE can set Remote-User: admin themselves and fully impersonate any
; user, including an admin, with ZERO authentication. Only leave it empty when
; network topology guarantees PHP is reachable solely via the trusted proxy
; (e.g. local development), never in a real deployment.
;
; Exact IP match only (no CIDR). Example (single proxy): TRUSTED_PROXIES=10.10.10.27
; Example (multiple): TRUSTED_PROXIES=10.10.10.27,10.10.10.28
; ============================================================================
TRUSTED_PROXIES=
# Timezone (default: America/New_York)
; Timezone (default: America/New_York)
TIMEZONE=America/New_York
# LDAP / lldap (for user avatar lookups)
; 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_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)
LDAP_BASE_DN="dc=example,dc=com"
LDAP_USER_BASE="ou=people,dc=example,dc=com"
; How long to cache avatar images locally (seconds, default 3600)
AVATAR_CACHE_TTL=3600
; Session-based rate limits (requests per 60s window). These govern
; browser/session traffic on general and API endpoints respectively;
; Bearer-key API traffic is rate-limited separately, per API key.
RATE_LIMIT_DEFAULT=100
RATE_LIMIT_API=60
+16 -2
View File
@@ -362,7 +362,6 @@ tinker_tickets/
│ ├── Database.php # Centralized mysqli connection
│ ├── ErrorHandler.php # Global error/exception handler
│ ├── NotificationHelper.php # Matrix hookshot webhook events
│ ├── OutputHelper.php # Safe HTML output helpers
│ ├── ResponseHelper.php # JSON API response helpers
│ ├── SynapseHelper.php # Resolves usernames → Matrix IDs via Synapse admin API
│ └── UrlHelper.php # Canonical ticket URLs using APP_DOMAIN
@@ -448,6 +447,21 @@ APP_DOMAIN=your.domain.example
TIMEZONE=America/New_York
```
**⚠️ REQUIRED FOR PRODUCTION — `TRUSTED_PROXIES`:** This app trusts Authelia
forward-auth headers (`Remote-User`, `Remote-Groups`, etc.) to identify who's
logged in. `TRUSTED_PROXIES` restricts that trust to requests that actually
came through your reverse proxy — **leaving it empty disables that check
entirely**, and anyone who can reach the PHP backend directly (a
misconfigured firewall rule, an exposed container port, SSRF from another
internal service) can set `Remote-User: admin` themselves and fully
impersonate any user with zero authentication. Set it to your reverse proxy's
IP address(es) before deploying anywhere reachable beyond your own machine:
```env
TRUSTED_PROXIES=10.10.10.27
```
`GET /api/health.php` reports a `warning` on the `trusted_proxies` check if
this is left empty, so it doesn't go unnoticed after deployment.
Matrix notification variables (all optional):
```env
# hookshot generic webhook URL — send events to Matrix room
@@ -556,7 +570,7 @@ Key conventions and gotchas for working with this codebase:
21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`.
22. **`utils.js` on all pages**: `utils.js` is loaded by all views (including admin). It provides `escapeHtml()`, `getTicketIdFromUrl()`, and `showConfirmModal()`.
23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`.
24. **Stats cache**: `StatsModel` caches stats for 60 s. Any path that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after the change. Callers: `TicketController::create` (manual create), `create_ticket_api.php` (external API create/escalate/reopen), `cron/create_recurring_tickets.php`, `bulk_operation`, `assign_ticket`, `update_ticket`, and `clone_ticket`.
24. **Stats cache**: `StatsModel` caches stats for 60 s. Any path that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after the change. Callers: `TicketController::create` (manual create), `create_ticket_api.php` (external API create/escalate/reopen), `cron/create_recurring_tickets.php`, `bulk_operation`, `assign_ticket`, `update_ticket`, `clone_ticket`, and `ticket_status_api.php` (Bearer API status-change endpoint).
25. **External API (`create_ticket_api.php`)**: Uses `ApiKeyAuth` (Bearer token), not session auth. Served directly by the web server from the document root — not through the index.php router. Includes deduplication logic (SHA-256 hash, no time window) that updates/escalates an existing open duplicate or reopens a closed one rather than creating a new ticket.
## File Reference
+17 -5
View File
@@ -1,8 +1,8 @@
<?php
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -99,6 +99,11 @@ try {
exit;
}
// Persist the trimmed text (not the raw client value) — matches update_comment.php
// and keeps stored comment_text free of leading whitespace that could shift a
// markdown-enabled comment's first line out of column 0 on reload.
$data['comment_text'] = $commentTextRaw;
// Never trust a client-supplied display name — always attribute the comment to
// the authenticated session user.
$data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
@@ -172,9 +177,16 @@ try {
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
$ticketVisibility = $ticket['visibility'] ?? 'public';
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API
if (!empty($mentionedUsers)) {
$mentionedUsernames = array_column($mentionedUsers, 'username');
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API.
// Only notify mentioned users who actually have access to this ticket;
// otherwise a mention would DM them the ticket's title and comment text
// even though canUserAccessTicket() would deny them the ticket itself.
$accessibleMentionedUsers = array_filter(
$mentionedUsers,
fn($u) => $ticketModel->canUserAccessTicket($ticket, $u)
);
if (!empty($accessibleMentionedUsers)) {
$mentionedUsernames = array_column($accessibleMentionedUsers, 'username');
$mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
if (!empty($mentionedMatrixIds)) {
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
+2 -1
View File
@@ -76,7 +76,8 @@ if ($assignedTo === null || $assignedTo === '') {
$ticket['title'] ?? "Ticket #{$ticketId}",
$assigneeName,
$assigneeMatrix,
$changedByDisplay
$changedByDisplay,
$ticket['visibility'] ?? 'public'
);
}
}
+2 -2
View File
@@ -10,8 +10,8 @@
* // $conn, $currentUser, $userId, $isAdmin are now available
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+4 -1
View File
@@ -1,5 +1,8 @@
<?php
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
@@ -25,7 +28,7 @@ if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
exit;
}
}
+3 -3
View File
@@ -5,8 +5,8 @@
* Creates a copy of an existing ticket with the same properties
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
header('Content-Type: application/json');
@@ -34,7 +34,7 @@ try {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
exit;
}
+3 -3
View File
@@ -5,8 +5,8 @@
* CRUD operations for custom field definitions
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
@@ -40,7 +40,7 @@ try {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
exit;
}
}
+3 -3
View File
@@ -7,8 +7,8 @@
*/
// Capture errors for debugging
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -48,7 +48,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Verify CSRF token
$csrfToken = $input['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ResponseHelper::forbidden('Invalid CSRF token');
ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]);
}
// Get attachment ID
+3 -3
View File
@@ -5,8 +5,8 @@
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -49,7 +49,7 @@ try {
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
exit;
}
+53 -3
View File
@@ -6,6 +6,9 @@
* Serves file downloads for ticket attachments
*/
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
@@ -102,12 +105,50 @@ try {
// Sanitize filename for Content-Disposition
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
$fileSize = filesize($filePath);
// Parse a single-range "Range: bytes=start-end" request header (RFC 7233).
// Multi-range requests aren't supported; they fall through to a full 200 response.
$rangeStart = 0;
$rangeEnd = $fileSize - 1;
$isRangeRequest = false;
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/^bytes=(\d*)-(\d*)$/', trim($_SERVER['HTTP_RANGE']), $m)) {
if ($m[1] === '' && $m[2] === '') {
// Malformed ("bytes=-") — ignore and serve the full file.
} elseif ($m[1] === '') {
// Suffix range: last N bytes
$suffixLength = (int)$m[2];
$rangeStart = max(0, $fileSize - $suffixLength);
$rangeEnd = $fileSize - 1;
$isRangeRequest = true;
} else {
$rangeStart = (int)$m[1];
$rangeEnd = ($m[2] === '') ? $fileSize - 1 : min((int)$m[2], $fileSize - 1);
$isRangeRequest = true;
}
if ($isRangeRequest && ($rangeStart > $rangeEnd || $rangeStart >= $fileSize)) {
http_response_code(416);
header('Content-Range: bytes */' . $fileSize);
exit;
}
}
$rangeLength = $rangeEnd - $rangeStart + 1;
header('Accept-Ranges: bytes');
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');
if ($isRangeRequest) {
http_response_code(206);
header('Content-Range: bytes ' . $rangeStart . '-' . $rangeEnd . '/' . $fileSize);
}
header('Content-Length: ' . $rangeLength);
// Prevent PHP from timing out on large files
set_time_limit(0);
@@ -125,9 +166,18 @@ try {
exit;
}
while (!feof($handle)) {
echo fread($handle, 8192);
fseek($handle, $rangeStart);
$remaining = $rangeLength;
$chunkSize = 8192;
while ($remaining > 0 && !feof($handle)) {
$read = ($remaining < $chunkSize) ? $remaining : $chunkSize;
$data = fread($handle, $read);
if ($data === false) {
break;
}
echo $data;
flush();
$remaining -= strlen($data);
}
fclose($handle);
+2 -2
View File
@@ -8,8 +8,8 @@
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+10 -3
View File
@@ -1,8 +1,8 @@
<?php
// API endpoint for generating API keys (Admin only)
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -39,8 +39,15 @@ try {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ob_end_clean();
http_response_code(403);
throw new Exception("Invalid CSRF token");
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'Invalid CSRF token',
'csrf_token' => CsrfMiddleware::getToken()
]);
exit;
}
}
+4 -2
View File
@@ -8,8 +8,10 @@
require_once __DIR__ . '/bootstrap.php';
try {
// Get all users for mentions/assignment
$result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username");
// Get all users for mentions/assignment. Capped as defense-in-depth against
// a single call scraping an unbounded user list — every caller only needs
// this for typeahead/dropdown filtering, never a literal full roster.
$result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username LIMIT 500");
if (!$result) {
throw new Exception("Failed to query users");
+51
View File
@@ -11,6 +11,9 @@
* - 503 Service Unavailable: System has issues
*/
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Don't apply rate limiting to health checks - they should always respond
header('Content-Type: application/json');
header('Cache-Control: no-cache, no-store, must-revalidate');
@@ -129,6 +132,54 @@ if (version_compare(PHP_VERSION, $requirements['min_php_version'], '>=')) {
$healthy = false;
}
// Check 7: memory_limit / max_execution_time sanity (warnings, not fatal — a
// low default doesn't fail requests until something large actually runs, so
// surface it here rather than waiting for a mysterious failure under load).
$memLimitIni = ini_get('memory_limit');
$memLimitUnit = strtolower(substr(trim($memLimitIni), -1));
$memLimitBytes = $memLimitIni === '-1'
? -1
: (int)$memLimitIni * match ($memLimitUnit) {
'g' => 1024 * 1024 * 1024,
'm' => 1024 * 1024,
'k' => 1024,
default => 1,
};
$minMemBytes = $requirements['min_memory_limit_mb'] * 1024 * 1024;
if ($memLimitBytes === -1 || $memLimitBytes >= $minMemBytes) {
$checks['memory_limit'] = ['status' => 'ok', 'message' => $memLimitIni];
} else {
$checks['memory_limit'] = [
'status' => 'warning',
'message' => sprintf('%s is below the recommended minimum %dM', $memLimitIni, $requirements['min_memory_limit_mb'])
];
}
$maxExecTime = (int)ini_get('max_execution_time');
if ($maxExecTime === 0 || $maxExecTime >= $requirements['min_max_execution_time']) {
$checks['max_execution_time'] = ['status' => 'ok', 'message' => (string)$maxExecTime];
} else {
$checks['max_execution_time'] = [
'status' => 'warning',
'message' => sprintf('%ds is below the recommended minimum %ds', $maxExecTime, $requirements['min_max_execution_time'])
];
}
// Check 8: TRUSTED_PROXIES configured. Empty disables enforceTrustedProxy()'s
// allowlist entirely, meaning anything that can reach this app directly can
// spoof the Authelia forward-auth Remote-* headers and impersonate any user,
// including an admin. Not fatal (a fresh/dev install may not sit behind a
// proxy yet), but should never go unnoticed on a real deployment.
if (!empty($GLOBALS['config']['TRUSTED_PROXIES'] ?? [])) {
$checks['trusted_proxies'] = ['status' => 'ok', 'message' => 'configured'];
} else {
$checks['trusted_proxies'] = [
'status' => 'warning',
'message' => 'TRUSTED_PROXIES is empty — forward-auth headers are NOT verified; '
. 'anything that can reach this app directly can impersonate any user'
];
}
// Calculate response time
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
+3 -3
View File
@@ -5,8 +5,8 @@
* CRUD operations for recurring_tickets table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
@@ -42,7 +42,7 @@ try {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
exit;
}
}
+3 -3
View File
@@ -5,8 +5,8 @@
* CRUD operations for ticket_templates table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
@@ -39,7 +39,7 @@ try {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
exit;
}
}
+3 -3
View File
@@ -5,8 +5,8 @@
* CRUD operations for status_transitions table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
@@ -40,7 +40,7 @@ try {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
exit;
}
}
+56 -6
View File
@@ -15,8 +15,10 @@
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
$prefsModel = new UserPreferencesModel($conn);
$ticketModel = new TicketModel($conn);
// ── POST: mark all read (update last_seen timestamp) ──────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
@@ -138,7 +140,7 @@ $statusSql = "SELECT DISTINCT
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 = ?
INNER JOIN ticket_watchers tw ON tw.ticket_id = al.entity_id AND tw.user_id = ?
WHERE al.action_type = 'update'
AND al.entity_type = 'ticket'
AND al.user_id != ?
@@ -204,7 +206,44 @@ foreach (array_merge($assignRows, $commentRows, $statusRows, $mentionRows) as $r
$all[] = $row;
}
usort($all, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
$all = array_slice($all, 0, 30);
// Re-check current ticket visibility before surfacing anything: a
// notification's audit_log entry reflects historical activity, but the
// ticket's visibility (or the user's group/watcher standing) may have
// tightened since. Without this, a notification still discloses the
// ticket's title and that activity occurred to someone who currently
// shouldn't see it, even though the ticket view's own access check would
// correctly reject them from opening it.
$candidateTicketIds = [];
foreach ($all as $row) {
$details = json_decode($row['details'] ?? '{}', true) ?? [];
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
? 'comment'
: $row['action_type'];
$tid = ($actionType === 'comment' || $actionType === 'mention')
? ($details['ticket_id'] ?? 0)
: $row['entity_id'];
if ($tid) {
$candidateTicketIds[(string)$tid] = true;
}
}
$ticketsById = !empty($candidateTicketIds)
? $ticketModel->getTicketsByIds(array_keys($candidateTicketIds))
: [];
$all = array_filter($all, function ($row) use ($ticketsById, $currentUser, $ticketModel) {
$details = json_decode($row['details'] ?? '{}', true) ?? [];
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
? 'comment'
: $row['action_type'];
$tid = (string)(($actionType === 'comment' || $actionType === 'mention')
? ($details['ticket_id'] ?? 0)
: $row['entity_id']);
$ticket = $ticketsById[$tid] ?? null;
return $ticket && $ticketModel->canUserAccessTicket($ticket, $currentUser);
});
$all = array_slice(array_values($all), 0, 30);
// Format for response
$notifications = [];
@@ -225,10 +264,21 @@ foreach ($all as $row) {
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
'mention' => "{$row['actor_name']} mentioned you on ticket #{$ticketId}",
'update' => (function () use ($row, $details, $ticketId) {
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}}
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
$to = $details['status']['to'] ?? ($details['new_value'] ?? '?');
return "{$row['actor_name']} changed status on #{$ticketId}: {$from}{$to}";
// Visibility changes log a flat {field, from, to} shape (api/update_ticket.php).
if (isset($details['field'], $details['from'], $details['to'])) {
return "{$row['actor_name']} changed {$details['field']} on #{$ticketId}: {$details['from']}{$details['to']}";
}
// Single/bulk field updates log a per-field delta, e.g.
// {"status": {"from": "Open", "to": "In Progress"}}. Only one field
// changed at a time is reported, in priority order below.
foreach (['status', 'priority', 'title', 'category', 'type', 'description'] as $field) {
if (isset($details[$field]['from'], $details[$field]['to'])) {
return "{$row['actor_name']} changed {$field} on #{$ticketId}: {$details[$field]['from']}{$details[$field]['to']}";
}
}
return "{$row['actor_name']} updated ticket #{$ticketId}";
})(),
default => "{$row['actor_name']} updated ticket #{$ticketId}",
};
+10 -3
View File
@@ -1,8 +1,8 @@
<?php
// API endpoint for revoking API keys (Admin only)
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -39,8 +39,15 @@ try {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ob_end_clean();
http_response_code(403);
throw new Exception("Invalid CSRF token");
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'Invalid CSRF token',
'csrf_token' => CsrfMiddleware::getToken()
]);
exit;
}
}
+2 -2
View File
@@ -18,8 +18,8 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+87
View File
@@ -0,0 +1,87 @@
<?php
/**
* Save custom field values for a ticket.
*
* POST { ticket_id, values: { [field_id]: value, ... } }
*
* Only fields applicable to the ticket's current category (or category-less
* fields) are considered; anything else in `values` is ignored rather than
* persisted, so a value typed for a field that no longer applies (e.g. the
* category changed) can't linger as orphaned/misleading data.
*/
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
apiRespond(['success' => false, 'error' => 'Method not allowed']);
}
$data = json_decode(file_get_contents('php://input'), true);
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
$values = is_array($data['values'] ?? null) ? $data['values'] : [];
if ($ticketId === '') {
http_response_code(400);
apiRespond(['success' => false, 'error' => 'ticket_id required']);
}
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
apiRespond(['success' => false, 'error' => 'Ticket not found']);
}
$fieldModel = new CustomFieldModel($conn);
$definitions = $fieldModel->getAllDefinitions($ticket['category'], true);
$errors = [];
$toSave = [];
foreach ($definitions as $def) {
$fieldId = (int)$def['field_id'];
$raw = $values[$fieldId] ?? ($values[(string)$fieldId] ?? null);
if ($def['field_type'] === 'checkbox') {
$normalized = !empty($raw) ? '1' : '0';
} else {
$normalized = is_scalar($raw) ? trim((string)$raw) : '';
}
if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
$errors[] = $def['field_label'] . ' is required';
continue;
}
if ($def['field_type'] === 'select' && $normalized !== '') {
$allowedOptions = $def['field_options']['options'] ?? [];
if (!in_array($normalized, $allowedOptions, true)) {
$errors[] = $def['field_label'] . ' has an invalid selection';
continue;
}
}
if ($def['field_type'] === 'number' && $normalized !== '' && !is_numeric($normalized)) {
$errors[] = $def['field_label'] . ' must be a number';
continue;
}
$toSave[$fieldId] = $normalized;
}
if (!empty($errors)) {
http_response_code(422);
apiRespond(['success' => false, 'error' => implode('; ', $errors)]);
}
$fieldModel->setValues($ticketId, $toSave);
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
(new AuditLogModel($conn))->log($userId, 'update', 'ticket', $ticketId, [
'reason' => 'custom fields updated',
]);
apiRespond(['success' => true]);
+1 -1
View File
@@ -98,7 +98,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DEL
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ResponseHelper::forbidden('Invalid CSRF token');
ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]);
}
}
+4 -3
View File
@@ -22,8 +22,8 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
@@ -171,7 +171,8 @@ if ($currentStatus !== $newStatus) {
$currentStatus,
$newStatus,
(string)$ticket['title'],
$keyName
$keyName,
$ticket['visibility'] ?? 'public'
);
NotificationHelper::notifyWatchers(
$conn,
+2 -2
View File
@@ -14,8 +14,8 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Rate limiting (same pattern as the other Bearer API endpoints)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+6 -3
View File
@@ -5,8 +5,8 @@
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -104,7 +104,10 @@ try {
'update',
'comment',
(string)$commentId,
['comment_text_preview' => substr($commentText, 0, 100)]
[
'ticket_id' => $comment['ticket_id'] ?? null,
'comment_text_preview' => substr($commentText, 0, 100),
]
);
}
+6 -4
View File
@@ -1,8 +1,8 @@
<?php
// Enable error reporting for debugging
error_reporting(E_ALL);
ini_set('display_errors', 0); // Don't display errors in the response
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -267,7 +267,8 @@ try {
$currentTicket['status'],
$updateData['status'],
$updateData['title'],
$changedBy
$changedBy,
$currentTicket['visibility'] ?? 'public'
);
NotificationHelper::notifyWatchers(
$this->conn,
@@ -275,7 +276,8 @@ try {
$updateData['title'],
'status_changed',
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
(int)$this->userId
(int)$this->userId,
$currentTicket['visibility'] ?? 'public'
);
}
+104 -6
View File
@@ -7,8 +7,8 @@
*/
// Capture errors for debugging
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
@@ -29,6 +29,73 @@ require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
header('Content-Type: application/json');
/**
* Strip EXIF/metadata (including GPS) from an image file in place by
* decoding and re-encoding it via GD, which drops metadata chunks that
* aren't part of the pixel data. Best-effort: leaves the file untouched on
* any failure (corrupt image, unsupported format, GD unavailable) rather
* than blocking the upload — original bytes are what would have been stored
* anyway before this existed.
*
* download_attachment.php streams attachments back byte-for-byte to any user
* with ticket visibility, so an unstripped phone photo's embedded GPS data
* would otherwise leak a data center/office's physical location even on a
* Confidential-visibility ticket.
*/
function stripImageMetadata(string $path, string $mimeType): void
{
if (!extension_loaded('gd')) {
return;
}
// Guard against a decompression-bomb-style crafted image (small file,
// huge decoded pixel buffer) exhausting memory during decode.
$dims = @getimagesize($path);
if ($dims === false) {
return;
}
[$width, $height] = $dims;
if ($width * $height > 40_000_000) { // ~40 MP cap
return;
}
$loaders = [
'image/jpeg' => 'imagecreatefromjpeg',
'image/png' => 'imagecreatefrompng',
'image/gif' => 'imagecreatefromgif',
'image/webp' => 'imagecreatefromwebp',
];
$loader = $loaders[$mimeType] ?? null;
if ($loader === null || !function_exists($loader)) {
return;
}
$image = @$loader($path);
if ($image === false) {
return;
}
// Preserve transparency for formats that support it.
imagesavealpha($image, true);
imagealphablending($image, false);
$tmpPath = $path . '.tmp';
$saved = match ($mimeType) {
'image/jpeg' => imagejpeg($image, $tmpPath, 90),
'image/png' => imagepng($image, $tmpPath, 6),
'image/gif' => imagegif($image, $tmpPath),
'image/webp' => imagewebp($image, $tmpPath, 90),
default => false,
};
imagedestroy($image);
if ($saved && file_exists($tmpPath)) {
rename($tmpPath, $path);
} elseif (file_exists($tmpPath)) {
unlink($tmpPath);
}
}
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
ResponseHelper::unauthorized();
@@ -47,6 +114,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
ResponseHelper::error('Invalid ticket ID format');
}
$offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0;
$limit = isset($_GET['limit']) ? min(100, max(1, (int)$_GET['limit'])) : 40;
try {
$conn = Database::getConnection();
$ticketModel = new TicketModel($conn);
@@ -56,7 +126,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
}
$attachmentModel = new AttachmentModel($conn);
$attachments = $attachmentModel->getAttachments($ticketId);
$total = $attachmentModel->getAttachmentCount($ticketId);
$attachments = $attachmentModel->getAttachments($ticketId, $limit, $offset);
// Add formatted file size and icon to each attachment
foreach ($attachments as &$att) {
@@ -64,7 +135,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
}
ResponseHelper::success(['attachments' => $attachments]);
ResponseHelper::success([
'attachments' => $attachments,
'total' => $total,
'offset' => $offset,
'limit' => $limit,
'has_more' => ($offset + $limit) < $total,
]);
} catch (Exception $e) {
ResponseHelper::serverError('Failed to load attachments');
}
@@ -78,7 +155,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
// Verify CSRF token
$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ResponseHelper::forbidden('Invalid CSRF token');
ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]);
}
// Get ticket ID
@@ -127,6 +204,23 @@ if ($file['size'] > $maxSize) {
ResponseHelper::error('File size exceeds maximum allowed (' . AttachmentModel::formatFileSize($maxSize) . ')');
}
// Check per-ticket attachment count/storage quota — bounds an authenticated
// low-privilege user slowly filling the uploads/ disk across many tickets,
// which was previously bounded only by the request-rate limiter, not volume.
$attachmentModel = new AttachmentModel($conn);
$maxAttachments = $GLOBALS['config']['MAX_ATTACHMENTS_PER_TICKET'] ?? 50;
if ($attachmentModel->getAttachmentCount($ticketId) >= $maxAttachments) {
ResponseHelper::error("This ticket already has the maximum of {$maxAttachments} attachments");
}
$maxTotalSize = $GLOBALS['config']['MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET'] ?? 104857600;
if ($attachmentModel->getTotalSizeForTicket($ticketId) + $file['size'] > $maxTotalSize) {
ResponseHelper::error(
'This upload would exceed the ticket\'s total attachment size limit of '
. AttachmentModel::formatFileSize($maxTotalSize)
);
}
// Get MIME type
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
@@ -184,6 +278,11 @@ if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
ResponseHelper::serverError('Failed to move uploaded file');
}
// Strip EXIF/GPS metadata from image uploads before it's ever served back
if (str_starts_with($mimeType, 'image/')) {
stripImageMetadata($targetPath, $mimeType);
}
// Sanitize original filename
$originalFilename = basename($file['name']);
$originalFilename = preg_replace('/[^\w\s\-\.]/', '', $originalFilename);
@@ -193,7 +292,6 @@ if (empty($originalFilename)) {
// Save to database
try {
$attachmentModel = new AttachmentModel($conn);
$attachmentId = $attachmentModel->addAttachment(
$ticketId,
$uniqueFilename,
+2 -2
View File
@@ -11,8 +11,8 @@
* Returns 404 if the user has no avatar set in lldap.
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+19 -14
View File
@@ -12,40 +12,43 @@ require_once dirname(__DIR__) . '/models/TicketModel.php';
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$ticketId = isset($_GET['ticket_id'])
? (int)$_GET['ticket_id']
: (int)($data['ticket_id'] ?? 0);
$ticketIdRaw = isset($_GET['ticket_id']) ? $_GET['ticket_id'] : ($data['ticket_id'] ?? '');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$ticketId = (int)($data['ticket_id'] ?? 0);
$ticketIdRaw = $data['ticket_id'] ?? '';
$action = $data['action'] ?? '';
if ($ticketId <= 0 || !in_array($action, ['watch', 'unwatch'], true)) {
if ($ticketIdRaw === '' || !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);
$ticket = $ticketModel->getTicketById((string)$ticketIdRaw);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
// Use the canonical ticket_id string from the fetched ticket row, not the
// raw request value, so ticket_watchers always stores exactly what's in
// tickets.ticket_id.
$ticketId = $ticket['ticket_id'];
if ($action === 'watch') {
$stmt = $conn->prepare(
"INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)"
);
$stmt->bind_param("ii", $ticketId, $userId);
$stmt->bind_param("si", $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->bind_param("si", $ticketId, $userId);
$stmt->execute();
$stmt->close();
}
@@ -54,7 +57,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$countStmt = $conn->prepare(
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
);
$countStmt->bind_param("i", $ticketId);
$countStmt->bind_param("s", $ticketId);
$countStmt->execute();
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
$countStmt->close();
@@ -73,7 +76,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
exit;
}
if ($ticketId <= 0) {
if ($ticketIdRaw === '') {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ticket_id required']);
exit;
@@ -83,17 +86,19 @@ if ($ticketId <= 0) {
// restricted ticket's watcher list and count aren't disclosed (the POST path
// already checks this).
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
$ticket = $ticketModel->getTicketById((string)$ticketIdRaw);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
$ticketId = $ticket['ticket_id'];
$watchingStmt = $conn->prepare(
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
);
$watchingStmt->bind_param("ii", $ticketId, $userId);
$watchingStmt->bind_param("si", $ticketId, $userId);
$watchingStmt->execute();
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
$watchingStmt->close();
@@ -107,7 +112,7 @@ $watchersStmt = $conn->prepare(
ORDER BY tw.created_at ASC
LIMIT 6"
);
$watchersStmt->bind_param("i", $ticketId);
$watchersStmt->bind_param("s", $ticketId);
$watchersStmt->execute();
$watchersResult = $watchersStmt->get_result();
$watchers = [];
@@ -118,7 +123,7 @@ $watchersStmt->close();
// True watcher count (the list above is capped at 6 for the avatar group)
$countStmt = $conn->prepare("SELECT COUNT(*) AS cnt FROM ticket_watchers WHERE ticket_id = ?");
$countStmt->bind_param("i", $ticketId);
$countStmt->bind_param("s", $ticketId);
$countStmt->execute();
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
$countStmt->close();
+17 -8
View File
@@ -87,11 +87,17 @@ function performAdvancedSearch(event) {
params.set('search', searchText);
}
// Date ranges
const createdFrom = document.getElementById('adv-created-from').value;
const createdTo = document.getElementById('adv-created-to').value;
const updatedFrom = document.getElementById('adv-updated-from').value;
const updatedTo = document.getElementById('adv-updated-to').value;
// Date ranges — swap if the user entered an end date before the start date
let createdFrom = document.getElementById('adv-created-from').value;
let createdTo = document.getElementById('adv-created-to').value;
if (createdFrom && createdTo && createdFrom > createdTo) {
[createdFrom, createdTo] = [createdTo, createdFrom];
}
let updatedFrom = document.getElementById('adv-updated-from').value;
let updatedTo = document.getElementById('adv-updated-to').value;
if (updatedFrom && updatedTo && updatedFrom > updatedTo) {
[updatedFrom, updatedTo] = [updatedTo, updatedFrom];
}
if (createdFrom) params.set('created_from', createdFrom);
if (createdTo) params.set('created_to', createdTo);
@@ -105,9 +111,12 @@ function performAdvancedSearch(event) {
params.set('status', selectedStatuses.join(','));
}
// Priority range
const priorityMin = document.getElementById('adv-priority-min').value;
const priorityMax = document.getElementById('adv-priority-max').value;
// Priority range — swap if min > max so the range is always satisfiable
let priorityMin = document.getElementById('adv-priority-min').value;
let priorityMax = document.getElementById('adv-priority-max').value;
if (priorityMin && priorityMax && Number(priorityMin) > Number(priorityMax)) {
[priorityMin, priorityMax] = [priorityMax, priorityMin];
}
if (priorityMin) params.set('priority_min', priorityMin);
if (priorityMax) params.set('priority_max', priorityMax);
+113 -1
View File
@@ -241,6 +241,11 @@
trigger.focus();
}
}
// Announce the close so whoever opened the modal can undo optimistic UI or
// clean up a dynamically-inserted overlay. A modal can be dismissed four
// ways — the ✕ button, a Cancel button, a backdrop click, and Escape — and
// the last two are handled globally here, so button-only listeners miss them.
el.dispatchEvent(new CustomEvent('lt:modalclose', { bubbles: true }));
}
function closeAllModals() {
@@ -2470,6 +2475,101 @@
list.addEventListener('drop', e => { e.preventDefault(); });
// Touch fallback — iOS Safari doesn't implement HTML5 drag-and-drop on
// arbitrary elements at all, and mobile Chrome's support is poor, so
// kanban drag was effectively unusable via touch without this. Touch
// events for a given touch point are always dispatched to the element
// touchstart fired on (per spec), so per-list local state here is safe;
// cross-list moves are resolved via elementFromPoint against the live
// finger position, same as dragover does via e.target above.
const DRAG_THRESHOLD = 8; // px of movement before a touch starts a drag
let _touchItem = null, _touchDragging = false;
let _touchStartX = 0, _touchStartY = 0, _touchOffsetX = 0, _touchOffsetY = 0;
function _touchTargetList(x, y) {
const el = document.elementFromPoint(x, y);
const found = el ? el.closest('[data-sortable-group]') : null;
return found && (found === list || _sameGroup(found)) ? found : null;
}
list.addEventListener('touchstart', e => {
const item = e.target.closest('[data-sortable-item]');
if (!item || !list.contains(item)) return;
if (handle && !e.target.closest(handle)) return;
const t = e.touches[0];
_touchItem = item;
_touchDragging = false;
_touchStartX = t.clientX;
_touchStartY = t.clientY;
}, { passive: true });
// touchmove/touchend/touchcancel are registered on document, not list:
// once the dragged item is reparented to document.body below, it's no
// longer a descendant of list, so events targeting it (touch events
// keep targeting their touchstart element for the whole gesture) would
// stop bubbling to a listener on list.
document.addEventListener('touchmove', e => {
if (!_touchItem) return;
const t = e.touches[0];
if (!_touchDragging) {
if (Math.abs(t.clientX - _touchStartX) < DRAG_THRESHOLD && Math.abs(t.clientY - _touchStartY) < DRAG_THRESHOLD) return;
// Drag intent confirmed — take over from here, blocking page scroll.
_touchDragging = true;
_srtDragging = _touchItem;
_srtSrcList = list;
_srtPlaceholder = _makePlaceholder(_touchItem);
_touchItem.classList.add('is-dragging');
const rect = _touchItem.getBoundingClientRect();
_touchOffsetX = _touchStartX - rect.left;
_touchOffsetY = _touchStartY - rect.top;
_touchItem.parentNode.insertBefore(_srtPlaceholder, _touchItem);
_touchItem.style.position = 'fixed';
_touchItem.style.zIndex = '1000';
_touchItem.style.width = rect.width + 'px';
_touchItem.style.pointerEvents = 'none';
document.body.appendChild(_touchItem); // avoid clipping by an overflow:hidden ancestor
}
e.preventDefault();
_touchItem.style.left = (t.clientX - _touchOffsetX) + 'px';
_touchItem.style.top = (t.clientY - _touchOffsetY) + 'px';
const targetList = _touchTargetList(t.clientX, t.clientY);
if (!targetList) return;
const overEl = document.elementFromPoint(t.clientX, t.clientY);
const over = overEl ? overEl.closest('[data-sortable-item]') : null;
if (over && over !== _srtDragging && targetList.contains(over)) {
const rect = over.getBoundingClientRect();
targetList.insertBefore(_srtPlaceholder, t.clientY < rect.top + rect.height / 2 ? over : over.nextSibling);
} else if (!targetList.contains(_srtPlaceholder)) {
targetList.appendChild(_srtPlaceholder);
}
}, { passive: false });
function _touchEnd() {
if (_touchDragging && _srtDragging) {
_srtDragging.classList.remove('is-dragging');
_srtDragging.style.position = '';
_srtDragging.style.zIndex = '';
_srtDragging.style.width = '';
_srtDragging.style.pointerEvents = '';
_srtDragging.style.left = '';
_srtDragging.style.top = '';
if (_srtPlaceholder && _srtPlaceholder.parentNode) {
_srtPlaceholder.parentNode.insertBefore(_srtDragging, _srtPlaceholder);
_srtPlaceholder.remove();
}
if (onSort) onSort(_getItems(), _srtDragging);
bus.emit('sortable:change', { list, items: _getItems(), moved: _srtDragging });
}
_touchItem = null; _touchDragging = false;
_srtDragging = null; _srtPlaceholder = null; _srtSrcList = null;
}
document.addEventListener('touchend', _touchEnd);
document.addEventListener('touchcancel', _touchEnd);
return {
refresh() { Array.from(list.children).forEach(child => { if (!child.hasAttribute('data-sortable-item')) _mark(child); }); },
getOrder: () => _getItems().map(el => el.dataset.id || el.textContent.trim()),
@@ -2701,7 +2801,7 @@
};
// Patch lt.api — auth-aware wrapper (renamed to avoid strict-mode duplicate declaration)
async function _apiFetchAuth(method, url, body) {
async function _apiFetchAuth(method, url, body, retried) {
if (_authAccess && auth.isExpiringSoon()) await auth.refresh();
const opts = { method, headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()) };
if (_authAccess) opts.headers['Authorization'] = 'Bearer ' + _authAccess;
@@ -2721,6 +2821,15 @@
// Resync CSRF token from any response body that carries a fresh one
// (bootstrap rotates on success and returns the current token on rejection).
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
// Auto-retry once on a stale-CSRF-token 403: the token lifetime (1h) is
// shorter than the session idle timeout (5h), so this is a routine,
// recoverable case (an hour of inactivity, or a write in another tab
// rotating the shared token) rather than a real rejection — resyncing
// above already has the fresh token, so silently resending once succeeds
// transparently instead of surfacing a confusing error on the first try.
if (resp.status === 403 && !retried && data && data.csrf_token) {
return _apiFetchAuth(method, url, body, true);
}
if (!resp.ok) {
const err = new Error(data.error || data.message || 'HTTP ' + resp.status);
err.data = data;
@@ -2774,6 +2883,9 @@
setTimeout(() => { if (modalEl && modalEl.parentNode) modalEl.remove(); }, 300);
resolve(value);
};
// Any dismissal counts as "no comment given", including a backdrop click or
// Escape, which close the overlay through the global handlers above.
modalEl.addEventListener('lt:modalclose', () => finish(null));
modalEl.querySelector('[data-modal-close]').addEventListener('click', () => finish(null));
document.getElementById(modalId + '_cancel').addEventListener('click', () => finish(null));
document.getElementById(modalId + '_confirm').addEventListener('click', () => {
+40 -92
View File
@@ -297,8 +297,12 @@ function clearAllFilters() {
params.delete('type');
params.delete('assigned_to');
params.delete('search');
params.delete('date_from');
params.delete('date_to');
params.delete('created_from');
params.delete('created_to');
params.delete('updated_from');
params.delete('updated_to');
params.delete('closed_from');
params.delete('closed_to');
params.delete('page');
// Keep sort parameters
@@ -357,14 +361,9 @@ function initSidebarFilters() {
}
if (clearFiltersBtn) {
clearFiltersBtn.addEventListener('click', () => {
const params = new URLSearchParams(window.location.search);
['status','category','type',
'created_from','created_to','updated_from','updated_to','closed_from','closed_to'
].forEach(k => params.delete(k));
params.set('page', '1');
window.location.search = params.toString();
});
// Delegate to clearAllFilters() so both controls always clear the same
// complete set of filter params instead of two independently-maintained lists.
clearFiltersBtn.addEventListener('click', clearAllFilters);
}
}
@@ -381,73 +380,6 @@ function initSettingsModal() {
}
}
function sortTable(table, column) {
const headers = table.querySelectorAll('th');
headers.forEach(header => {
header.classList.remove('sort-asc', 'sort-desc');
});
const rows = Array.from(table.querySelectorAll('tbody tr'));
const currentDirection = table.dataset.sortColumn == column
? (table.dataset.sortDirection === 'asc' ? 'desc' : 'asc')
: 'asc';
table.dataset.sortColumn = column;
table.dataset.sortDirection = currentDirection;
rows.sort((a, b) => {
const aValue = a.children[column].textContent.trim();
const bValue = b.children[column].textContent.trim();
// Check if this is a date column — prefer data-ts attribute over text (which may be relative)
const headerText = headers[column].textContent.toLowerCase();
if (headerText === 'created' || headerText === 'updated') {
const cellA = a.children[column];
const cellB = b.children[column];
const dateA = new Date(cellA.dataset.ts || aValue);
const dateB = new Date(cellB.dataset.ts || bValue);
return currentDirection === 'asc' ? dateA - dateB : dateB - dateA;
}
// Special handling for "Assigned To" column
if (headerText === 'assigned to') {
const aUnassigned = aValue === 'Unassigned';
const bUnassigned = bValue === 'Unassigned';
// Both unassigned - equal
if (aUnassigned && bUnassigned) return 0;
// Put unassigned at the end regardless of sort direction
if (aUnassigned) return 1;
if (bUnassigned) return -1;
// Otherwise sort names normally
return currentDirection === 'asc'
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
}
// Numeric comparison
const numA = parseFloat(aValue);
const numB = parseFloat(bValue);
if (!isNaN(numA) && !isNaN(numB)) {
return currentDirection === 'asc' ? numA - numB : numB - numA;
}
// String comparison
return currentDirection === 'asc'
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
});
const currentHeader = headers[column];
currentHeader.classList.add(currentDirection === 'asc' ? 'sort-asc' : 'sort-desc');
const tbody = table.querySelector('tbody');
rows.forEach(row => tbody.appendChild(row));
}
// Old settings modal functions removed - now using settings.js with new settings modal
@@ -550,7 +482,7 @@ function bulkClose() {
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('bulkCloseModal');
openModalWithDismiss('bulkCloseModal', closeBulkCloseModal);
}
function closeBulkCloseModal() {
@@ -633,7 +565,7 @@ function showBulkAssignModal() {
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('bulkAssignModal');
openModalWithDismiss('bulkAssignModal', closeBulkAssignModal);
setTimeout(() => { const inp = document.getElementById('bulkAssignUserInput'); if (inp) inp.focus(); }, 120);
lt.api.get('/api/get_users.php')
@@ -731,7 +663,7 @@ function showBulkPriorityModal() {
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('bulkPriorityModal');
openModalWithDismiss('bulkPriorityModal', closeBulkPriorityModal);
}
function closeBulkPriorityModal() {
@@ -845,7 +777,7 @@ function showBulkStatusModal() {
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('bulkStatusModal');
openModalWithDismiss('bulkStatusModal', closeBulkStatusModal);
}
function closeBulkStatusModal() {
@@ -942,7 +874,7 @@ function showBulkDeleteModal() {
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('bulkDeleteModal');
openModalWithDismiss('bulkDeleteModal', closeBulkDeleteModal);
}
function closeBulkDeleteModal() {
@@ -1032,6 +964,22 @@ function showInputModal(title, label, placeholder = '', onSubmit, onCancel = nul
input.addEventListener('keypress', (e) => { if (e.key === 'Enter') handleSubmit(); });
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
// Backdrop click / Escape close the overlay via base.js's global handlers.
modal.addEventListener('lt:modalclose', () => cleanup(onCancel));
}
/**
* Open a dynamically-inserted modal and make sure it tears itself down however it
* is dismissed. base.js handles backdrop clicks and Escape globally, so wiring
* only the ✕/Cancel buttons leaves the overlay in the DOM — and the next open
* inserts a second element with the same id, which then shadows the live one.
*/
function openModalWithDismiss(modalId, onDismiss) {
lt.modal.open(modalId);
const el = document.getElementById(modalId);
// lt.modal.close() early-returns once .is-open is gone, so the close call
// inside onDismiss cannot re-enter this listener.
if (el) el.addEventListener('lt:modalclose', onDismiss);
}
// ========================================
@@ -1069,7 +1017,7 @@ function quickStatusChange(ticketId, currentStatus) {
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('quickStatusModal');
openModalWithDismiss('quickStatusModal', closeQuickStatusModal);
}
function closeQuickStatusModal() {
@@ -1119,12 +1067,11 @@ function quickAssign(ticketId) {
<div class="lt-modal-body">
<p class="lt-mb-xs lt-text-muted lt-text-xs">Ticket #${lt.escHtml(String(ticketId))}</p>
<label class="lt-label">Assign to:</label>
<div class="lt-combobox" id="quickAssignCombobox">
<div class="lt-combobox-input-wrap">
<input type="text" class="lt-combobox-input" id="quickAssignInput"
placeholder="Search users" autocomplete="off" aria-label="Search users">
</div>
<ul class="lt-combobox-list" role="listbox" aria-hidden="true"></ul>
<div class="lt-typeahead" id="quickAssignTypeahead" style="position:relative">
<input type="text" class="lt-input lt-w-full" id="quickAssignInput"
placeholder="Search users…" autocomplete="off" spellcheck="false"
aria-label="Search users" aria-autocomplete="list">
<div class="lt-typeahead-dropdown" id="quickAssignDropdown"></div>
</div>
</div>
<div class="lt-modal-footer">
@@ -1136,7 +1083,7 @@ function quickAssign(ticketId) {
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('quickAssignModal');
openModalWithDismiss('quickAssignModal', closeQuickAssignModal);
lt.api.get('/api/get_users.php')
.then(data => {
@@ -1150,7 +1097,9 @@ function quickAssign(ticketId) {
label: u.display_name || u.username
}))
];
lt.combobox.init(input, items, {
lt.typeahead.init(input, items, {
minChars: 1,
maxResults: 8,
onSelect: function(item) { _quickAssignUserId = item.value || null; }
});
}
@@ -1199,7 +1148,6 @@ function setViewMode(mode) {
if (mode === 'card') {
populateKanbanCards();
}
localStorage.setItem('ticketViewMode', mode);
}
/**
+36 -11
View File
@@ -354,6 +354,33 @@ window.renderMarkdownElements = renderMarkdownElements;
// Rich Text Editor Toolbar Functions
// ========================================
/**
* Replace textarea.value.substring(selStart, selEnd) with replacementText,
* preserving the browser's native undo/redo stack via
* document.execCommand('insertText', ...) -- the same mechanism real typing
* uses -- instead of a direct .value assignment, which discards the entire
* undo history. Falls back to a direct assignment (losing undo, matching the
* old behavior) only if execCommand is unavailable or unsuccessful.
*/
function insertTextPreservingUndo(textarea, replacementText, selStart, selEnd) {
textarea.focus();
textarea.setSelectionRange(selStart, selEnd);
let inserted = false;
if (typeof document.execCommand === 'function') {
try {
inserted = document.execCommand('insertText', false, replacementText);
} catch (e) {
inserted = false;
}
}
if (!inserted) {
const text = textarea.value;
textarea.value = text.substring(0, selStart) + replacementText + text.substring(selEnd);
}
}
/**
* Insert markdown formatting around selection
*/
@@ -363,16 +390,13 @@ function insertMarkdownFormat(textareaId, prefix, suffix) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selectedText = text.substring(start, end);
const selectedText = textarea.value.substring(start, end);
// Insert formatting
const newText = text.substring(0, start) + prefix + selectedText + suffix + text.substring(end);
textarea.value = newText;
insertTextPreservingUndo(textarea, prefix + selectedText + suffix, start, end);
// Set cursor position
if (selectedText) {
textarea.setSelectionRange(start + prefix.length, end + prefix.length);
textarea.setSelectionRange(start + prefix.length, start + prefix.length + selectedText.length);
} else {
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
}
@@ -391,9 +415,10 @@ function insertMarkdownText(textareaId, text) {
if (!textarea) return;
const start = textarea.selectionStart;
const value = textarea.value;
textarea.value = value.substring(0, start) + text + value.substring(start);
// Matches the prior behavior: insert before the selection start without
// deleting any currently-selected text (a collapsed replace range).
insertTextPreservingUndo(textarea, text, start, start);
textarea.setSelectionRange(start + text.length, start + text.length);
textarea.focus();
@@ -453,7 +478,7 @@ function toolbarList(textareaId) {
}
// Insert list marker at beginning of line
textarea.value = text.substring(0, lineStart) + '- ' + text.substring(lineStart);
insertTextPreservingUndo(textarea, '- ', lineStart, lineStart);
textarea.setSelectionRange(start + 2, start + 2);
textarea.focus();
@@ -474,7 +499,7 @@ function toolbarHeading(textareaId) {
}
// Insert heading marker at beginning of line
textarea.value = text.substring(0, lineStart) + '## ' + text.substring(lineStart);
insertTextPreservingUndo(textarea, '## ', lineStart, lineStart);
textarea.setSelectionRange(start + 3, start + 3);
textarea.focus();
@@ -495,7 +520,7 @@ function toolbarQuote(textareaId) {
}
// Insert quote marker at beginning of line
textarea.value = text.substring(0, lineStart) + '> ' + text.substring(lineStart);
insertTextPreservingUndo(textarea, '> ', lineStart, lineStart);
textarea.setSelectionRange(start + 2, start + 2);
textarea.focus();
+249 -20
View File
@@ -183,15 +183,35 @@ function toggleEditMode() {
}
/**
* Compute avatar color class from display name (mirrors PHP crc32 % 4 logic)
* CRC-32 (IEEE 802.3 / zlib polynomial), matching PHP's crc32(). Operates on
* the UTF-8 byte sequence, same as PHP, so results agree for non-ASCII names.
*/
function crc32(str) {
var bytes = unescape(encodeURIComponent(str));
var table = crc32._table || (crc32._table = (function () {
var t = [];
for (var n = 0; n < 256; n++) {
var c = n;
for (var k = 0; k < 8; k++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
t[n] = c;
}
return t;
})());
var crc = -1;
for (var i = 0; i < bytes.length; i++) {
crc = (crc >>> 8) ^ table[(crc ^ bytes.charCodeAt(i)) & 0xFF];
}
return (crc ^ -1) >>> 0;
}
/**
* Compute avatar color class from display name (mirrors PHP's crc32 % 4 logic)
*/
function avatarColorClass(displayName) {
var colors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
var h = 0;
for (var i = 0; i < displayName.length; i++) {
h = ((h << 5) - h + displayName.charCodeAt(i)) | 0;
}
return colors[Math.abs(h) % 4];
return colors[crc32(displayName) % 4];
}
/**
@@ -285,6 +305,14 @@ function addComment() {
const nc = document.getElementById('newComment');
if (nc) nc.value = '';
// Clear the live preview — clearing the textarea programmatically
// does not fire 'input', so updatePreview() never runs
const previewDiv = document.getElementById('markdownPreview');
if (previewDiv) {
previewDiv.innerHTML = '';
previewDiv.classList.add('is-hidden');
}
// Format the comment text for display
let displayText;
if (isMarkdownEnabled) {
@@ -424,6 +452,140 @@ function handleAssignmentChange() {
});
}
// ========================================
// SLA Priority-Alert Banner
// ========================================
const SLA_TARGET_HOURS = { 1: 8, 2: 24 };
const SLA_META = {
1: { cls: 'lt-sla-p1', icon: '[ ! ]', label: 'P1 Critical' },
2: { cls: 'lt-sla-p2', icon: '[ ~ ]', label: 'P2 High' },
};
let slaTickTimer = null;
function stopSlaTicker() {
if (slaTickTimer) {
clearInterval(slaTickTimer);
slaTickTimer = null;
}
}
function startSlaTicker(banner) {
stopSlaTicker();
const createdAt = parseInt(banner.dataset.createdAt, 10) * 1000;
const slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
const deadline = createdAt + slaMs;
const elapsedEl = document.getElementById('slaElapsedTimer');
const countdownEl = document.getElementById('slaCountdownTimer');
const overrunEl = document.getElementById('slaOverrunTimer');
const fillBar = document.getElementById('slaProgressBar');
const progressWrap = document.getElementById('slaProgress');
function fmtHMS(ms) {
const s = Math.floor(Math.abs(ms) / 1000);
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
return [h, m, ss].map(n => String(n).padStart(2, '0')).join(':');
}
function tick() {
const now = Date.now();
const elapsed = now - createdAt;
const remaining = deadline - now;
const pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
if (fillBar) fillBar.style.width = pct + '%';
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
if (remaining > 0) {
if (countdownEl) countdownEl.textContent = fmtHMS(remaining) + ' remaining';
} else if (overrunEl) {
overrunEl.textContent = fmtHMS(-remaining);
}
}
tick();
slaTickTimer = setInterval(tick, 1000);
}
/**
* Render, update, or remove the SLA priority-alert banner for the given
* priority, matching what a fresh page load would show. Called on initial
* load and again whenever the ticket's priority changes client-side, so the
* banner never goes stale until a reload.
*/
function renderSlaBanner(priorityNum) {
const anchor = document.getElementById('priorityAlertBannerAnchor');
const existing = document.getElementById('priorityAlertBanner');
const meta = SLA_META[priorityNum];
const status = window.ticketData && window.ticketData.status;
if (!meta || status === 'Closed') {
if (existing) {
stopSlaTicker();
existing.remove();
}
return;
}
const createdAtSec = window.ticketData && window.ticketData.created_at_ts;
if (!createdAtSec || !anchor) return;
const slaTargetHours = SLA_TARGET_HOURS[priorityNum];
const elapsedSeconds = Math.floor(Date.now() / 1000) - createdAtSec;
const slaBreached = elapsedSeconds >= slaTargetHours * 3600;
const slaPct = Math.min(100, Math.round((elapsedSeconds / (slaTargetHours * 3600)) * 100));
const slaId = 'sla-' + window.ticketData.id;
let dismissed = false;
try {
dismissed = !!sessionStorage.getItem('lt_sla_dismissed_' + slaId);
} catch (e) { /* sessionStorage unavailable */ }
const banner = existing || document.createElement('div');
if (!existing) {
banner.id = 'priorityAlertBanner';
banner.setAttribute('role', 'alert');
banner.setAttribute('aria-live', 'polite');
banner.style.marginBottom = '0.75rem';
anchor.appendChild(banner);
}
banner.className = meta.cls;
banner.dataset.slaId = slaId;
banner.dataset.createdAt = String(createdAtSec);
banner.dataset.slaHours = String(slaTargetHours);
banner.hidden = dismissed;
banner.innerHTML =
`<span class="lt-sla-icon" aria-hidden="true">${meta.icon}</span>` +
'<div class="lt-sla-info">' +
`<div class="lt-sla-title">${lt.escHtml(meta.label)} — SLA: <span id="slaElapsedTimer"></span> elapsed of ${slaTargetHours}h limit` +
(slaBreached ? '&nbsp;<span class="lt-text-danger" id="slaBreachLabel">BREACHED</span>' : '') +
'</div>' +
`<div class="lt-sla-bar" aria-label="SLA progress ${slaPct}%" id="slaProgress">` +
`<div class="lt-sla-fill" id="slaProgressBar" style="width:${slaPct}%"></div>` +
'</div>' +
'</div>' +
(slaBreached
? `<div class="lt-sla-meta lt-text-danger" id="slaCountdownTimer">+<span id="slaOverrunTimer">${Math.round((elapsedSeconds - slaTargetHours * 3600) / 360) / 10}h</span> over</div>`
: '<div class="lt-sla-meta" id="slaCountdownTimer"></div>') +
'<button type="button" class="lt-sla-dismiss" aria-label="Dismiss">&#x2715;</button>';
banner.querySelector('.lt-sla-dismiss').addEventListener('click', function() {
banner.hidden = true;
stopSlaTicker();
try { sessionStorage.setItem('lt_sla_dismissed_' + slaId, '1'); } catch (e) { /* ignore */ }
});
if (dismissed) {
stopSlaTicker();
} else {
startSlaTicker(banner);
}
}
/**
* Handle metadata field changes (priority, category, type)
*/
@@ -447,10 +609,12 @@ function handleMetadataChanges() {
// Update window.ticketData
window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue;
// For priority, update the TDS frame border accent
// For priority, update the TDS frame border accent and the
// SLA banner (which otherwise stays stale until reload)
if (fieldName === 'priority') {
const ticketFrame = document.querySelector('.lt-frame-ticket');
if (ticketFrame) ticketFrame.setAttribute('data-priority', newValue);
renderSlaBanner(window.ticketData.priority);
}
}
})
@@ -521,7 +685,19 @@ function updateTicketStatus() {
`);
const modal = document.getElementById(modalId);
lt.modal.open(modalId);
const cleanup = (ok) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (!ok) statusSelect.selectedIndex = 0; };
let settled = false;
const cleanup = (ok) => {
if (settled) return; // lt.modal.close() below re-enters via lt:modalclose
settled = true;
lt.modal.close(modalId);
setTimeout(() => modal.remove(), 300);
if (!ok) statusSelect.selectedIndex = 0;
};
// Backdrop click and Escape close the overlay through base.js's global
// handlers. Without this the dropdown kept displaying the new status
// while the server was never called, so the ticket looked closed until
// a reload revealed it was still open.
modal.addEventListener('lt:modalclose', () => cleanup(false));
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(false));
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(false));
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => {
@@ -987,35 +1163,62 @@ function resetUploadUI() {
}
}
function loadAttachments() {
const ticketId = window.ticketData.id;
const container = document.getElementById('attachmentsList');
const ATTACHMENT_PAGE_SIZE = 40;
let attachmentOffset = 0;
let attachmentTotal = 0;
function loadAttachments() {
const container = document.getElementById('attachmentsList');
if (!container) return;
lt.api.get(`/api/upload_attachment.php?ticket_id=${ticketId}`)
attachmentOffset = 0;
attachmentTotal = 0;
fetchAttachmentsPage(false);
}
function fetchAttachmentsPage(append) {
const ticketId = window.ticketData.id;
const container = document.getElementById('attachmentsList');
if (!container) return;
const loadMoreBtn = document.getElementById('attachmentsLoadMoreBtn');
if (loadMoreBtn) {
loadMoreBtn.disabled = true;
loadMoreBtn.textContent = 'Loading…';
}
lt.api.get(`/api/upload_attachment.php?ticket_id=${ticketId}&offset=${attachmentOffset}&limit=${ATTACHMENT_PAGE_SIZE}`)
.then(data => {
if (data.success) {
renderAttachments(data.attachments || []);
} else {
attachmentTotal = data.total;
attachmentOffset += (data.attachments || []).length;
renderAttachments(data.attachments || [], append, data.has_more);
} else if (!append) {
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
} else {
lt.toast.error('Error loading more attachments');
}
})
.catch(error => {
if (!append) {
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
} else {
lt.toast.error('Error loading more attachments');
}
});
}
function renderAttachments(attachments) {
function renderAttachments(attachments, append, hasMore) {
const container = document.getElementById('attachmentsList');
if (!container) return;
if (attachments.length === 0) {
if (!append && attachments.length === 0) {
container.innerHTML = '<p class="lt-text-muted">No files attached to this ticket.</p>';
return;
}
let html = '<div class="attachments-grid">';
let grid = append ? container.querySelector('.attachments-grid') : null;
let html = '';
attachments.forEach(att => {
const uploaderName = att.display_name || att.username || 'Unknown';
@@ -1028,7 +1231,7 @@ function renderAttachments(attachments) {
});
const uploadDate = `<span class="ts-cell" data-ts="${lt.escHtml(att.uploaded_at)}" title="${lt.escHtml(uploadDateFormatted)}">${lt.time.ago(att.uploaded_at)}</span>`;
const isImage = /\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(att.original_filename);
const isImage = /^image\//i.test(att.mime_type || '');
const imgUrl = `/api/download_attachment.php?id=${att.attachment_id}&inline=1`;
const iconHtml = isImage
? `<a href="${imgUrl}" class="lt-lightbox-trigger" data-lightbox="ticket-attachments" title="${lt.escHtml(att.original_filename)}">
@@ -1055,8 +1258,34 @@ function renderAttachments(attachments) {
</div>`;
});
html += '</div>';
container.innerHTML = html;
if (grid) {
const temp = document.createElement('div');
temp.innerHTML = html;
while (temp.firstChild) {
grid.appendChild(temp.firstChild);
}
} else {
container.innerHTML = '<div class="attachments-grid">' + html + '</div>';
}
const remaining = attachmentTotal - attachmentOffset;
let loadMoreBtn = document.getElementById('attachmentsLoadMoreBtn');
if (hasMore && remaining > 0) {
if (!loadMoreBtn) {
loadMoreBtn = document.createElement('button');
loadMoreBtn.type = 'button';
loadMoreBtn.id = 'attachmentsLoadMoreBtn';
loadMoreBtn.className = 'lt-btn lt-btn-sm lt-w-full';
loadMoreBtn.style.marginTop = '0.6rem';
loadMoreBtn.addEventListener('click', function() { fetchAttachmentsPage(true); });
container.appendChild(loadMoreBtn);
}
loadMoreBtn.disabled = false;
loadMoreBtn.textContent = `Load more attachments (${remaining} remaining)`;
} else if (loadMoreBtn) {
loadMoreBtn.remove();
}
// Initialize lightbox on image thumbnails
if (window.lt && lt.lightbox) {
lt.lightbox.init('.lt-lightbox-trigger', { caption: 'title', loop: true });
+10 -3
View File
@@ -106,6 +106,11 @@ $GLOBALS['config'] = [
'SESSION_TIMEOUT' => 18000, // 5 hours in seconds
'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes
// How often an already-logged-in session re-validates Remote-User/
// Remote-Groups against current Authelia/LLDAP state (AuthMiddleware).
// Without this, a revoked admin keeps full access for up to SESSION_TIMEOUT.
'PRIVILEGE_RESYNC_INTERVAL' => 300, // 5 minutes
// CSRF settings
'CSRF_LIFETIME' => 3600, // 1 hour in seconds
@@ -115,6 +120,8 @@ $GLOBALS['config'] = [
// File upload settings
'MAX_UPLOAD_SIZE' => 10485760, // 10MB in bytes
'MAX_ATTACHMENTS_PER_TICKET' => 50,
'MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET' => 104857600, // 100MB in bytes
'ALLOWED_FILE_TYPES' => [
'image/jpeg',
'image/png',
@@ -134,9 +141,9 @@ $GLOBALS['config'] = [
],
'UPLOAD_DIR' => __DIR__ . '/../uploads',
// Rate limiting
'RATE_LIMIT_DEFAULT' => 100, // Requests per minute for general
'RATE_LIMIT_API' => 60, // Requests per minute for API
// Rate limiting (requests per minute; read by RateLimitMiddleware)
'RATE_LIMIT_DEFAULT' => (int)($envVars['RATE_LIMIT_DEFAULT'] ?? 100), // Session-based, general endpoints
'RATE_LIMIT_API' => (int)($envVars['RATE_LIMIT_API'] ?? 60), // Session-based, API endpoints
// Audit log settings
'AUDIT_LOG_RETENTION_DAYS' => 90,
+7
View File
@@ -25,4 +25,11 @@ return [
'fileinfo', // api/upload_attachment.php — MIME validation
'json', // request/response encoding (bundled, but assert anyway)
],
// Sanity-check thresholds (warnings, not hard failures). A host with a low
// default memory_limit passes a bare extension/version check cleanly and
// only surfaces as a mysterious failure under real load — a large CSV
// export, an oversized dashboard query on a big install.
'min_memory_limit_mb' => 256,
'min_max_execution_time' => 30, // seconds; 0 (unlimited) always passes
];
+52
View File
@@ -7,6 +7,7 @@ require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
require_once dirname(__DIR__) . '/models/TemplateModel.php';
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
@@ -18,6 +19,7 @@ class TicketController
private $userModel;
private $workflowModel;
private $templateModel;
private $customFieldModel;
private $conn;
public function __construct($conn)
@@ -29,6 +31,7 @@ class TicketController
$this->userModel = new UserModel($conn);
$this->workflowModel = new WorkflowModel($conn);
$this->templateModel = new TemplateModel($conn);
$this->customFieldModel = new CustomFieldModel($conn);
}
public function view($id)
@@ -60,6 +63,11 @@ class TicketController
// Get allowed status transitions for this ticket
$allowedTransitions = $this->workflowModel->getAllowedTransitions($ticket['status']);
// Custom fields applicable to this ticket's category, with any
// already-saved values for it
$customFieldDefs = $this->customFieldModel->getAllDefinitions($ticket['category'], true);
$customFieldValues = $this->customFieldModel->getValuesForTicket($id);
// Make $conn available to view for visibility groups
$conn = $this->conn;
@@ -73,6 +81,12 @@ class TicketController
$currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null;
// All active custom field definitions (every category, plus
// category-less ones) — the create form renders them all and toggles
// visibility client-side as the Category select changes, since the
// ticket doesn't exist yet to scope the query to one category.
$allCustomFieldDefs = $this->customFieldModel->getAllDefinitions(null, true);
// Check if form was submitted
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validate CSRF token
@@ -131,6 +145,38 @@ class TicketController
return;
}
// Custom fields applicable to the submitted category — validate
// is_required server-side (the form is novalidate, and a field
// hidden by the client-side category toggle must not silently
// bypass a requirement that applies to the category actually
// submitted).
$submittedCustomFields = is_array($_POST['custom_fields'] ?? null) ? $_POST['custom_fields'] : [];
$applicableFieldDefs = array_filter(
$allCustomFieldDefs,
fn($def) => $def['category'] === null || $def['category'] === $ticketData['category']
);
$customFieldsToSave = [];
foreach ($applicableFieldDefs as $def) {
$fieldId = (int)$def['field_id'];
$raw = $submittedCustomFields[$fieldId] ?? null;
$normalized = $def['field_type'] === 'checkbox'
? (!empty($raw) ? '1' : '0')
: (is_scalar($raw) ? trim((string)$raw) : '');
if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
$error = $def['field_label'] . ' is required';
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn;
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
if ($normalized !== '') {
$customFieldsToSave[$fieldId] = $normalized;
}
}
// Create ticket with user tracking
$result = $this->ticketModel->createTicket($ticketData, $userId);
@@ -144,6 +190,12 @@ class TicketController
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($this->conn))->invalidateCache();
// Persist custom field values for the fields applicable to
// this ticket's category
if (!empty($customFieldsToSave)) {
$this->customFieldModel->setValues($result['ticket_id'], $customFieldsToSave);
}
// Auto-link as duplicate if requested from create form
$linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
+81 -53
View File
@@ -2,12 +2,16 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once __DIR__ . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Load environment variables with error check
$envFile = __DIR__ . '/.env';
if (!file_exists($envFile)) {
require_once __DIR__ . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
// Early friendly JSON error if .env is missing, before config.php's own
// (plain-text die()) handling would otherwise run — this is a JSON API
// endpoint and must always respond with a JSON body.
if (!file_exists(__DIR__ . '/.env')) {
echo json_encode([
'success' => false,
'error' => 'Configuration file not found'
@@ -15,37 +19,17 @@ if (!file_exists($envFile)) {
exit;
}
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
if (!$envVars) {
echo json_encode([
'success' => false,
'error' => 'Invalid configuration file'
]);
exit;
}
// Load application config so UrlHelper can resolve APP_DOMAIN, and so the
// DB connection below (via Database::getConnection()) gets the same
// charset/timezone sync as every other endpoint instead of a hand-rolled
// second connection.
require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/helpers/Database.php';
// Strip quotes from values if present (parse_ini_file may include them)
foreach ($envVars as $key => $value) {
if (is_string($value)) {
if (
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
) {
$envVars[$key] = substr($value, 1, -1);
}
}
}
// Database connection with detailed error handling
$conn = new mysqli(
$envVars['DB_HOST'],
$envVars['DB_USER'],
$envVars['DB_PASS'],
$envVars['DB_NAME']
);
if ($conn->connect_error) {
error_log('create_ticket_api: DB connection failed: ' . $conn->connect_error);
try {
$conn = Database::getConnection();
} catch (\Throwable $e) {
error_log('create_ticket_api: DB connection failed: ' . $e->getMessage());
http_response_code(500);
echo json_encode([
'success' => false,
@@ -54,13 +38,12 @@ if ($conn->connect_error) {
exit;
}
// Load application config so UrlHelper can resolve APP_DOMAIN
require_once __DIR__ . '/config/config.php';
// Authenticate via API key
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
require_once __DIR__ . '/models/AuditLogModel.php';
require_once __DIR__ . '/models/StatsModel.php';
require_once __DIR__ . '/models/TicketModel.php';
require_once __DIR__ . '/models/WorkflowModel.php';
require_once __DIR__ . '/helpers/UrlHelper.php';
$apiKeyAuth = new ApiKeyAuth($conn);
@@ -346,7 +329,7 @@ if ($existing) {
(new StatsModel($conn))->invalidateCache();
}
$conn->close();
Database::close();
echo json_encode([
'success' => true,
'ticket_id' => $existingId,
@@ -357,17 +340,52 @@ if ($existing) {
exit;
}
// Ticket was closed — reopen it and add a recurrence comment
$reopenStmt = $conn->prepare(
"UPDATE tickets SET status = 'Open', closed_at = NULL, updated_at = NOW(), updated_by = ? WHERE ticket_id = ?"
);
$reopenStmt->bind_param("is", $userId, $existingId);
$reopenStmt->execute();
$reopenStmt->close();
// Ticket was closed — reopen it and add a recurrence comment. Route
// through the Workflow Designer like every other status-write path in
// the app, rather than forcing status='Open' via raw SQL regardless of
// configured transition rules.
$workflowModel = new WorkflowModel($conn);
$reopenStatus = 'Open';
if (!$workflowModel->isTransitionAllowed('Closed', 'Open', false)) {
// Direct Closed->Open isn't configured — fall back to any transition
// the Workflow Designer does allow from Closed that this unattended,
// non-admin automation can actually satisfy (no comment prompt, no
// admin elevation). If even that doesn't exist, leave the ticket
// Closed rather than force an unconfigured state.
$reopenStatus = null;
foreach ($workflowModel->getAllowedTransitions('Closed') as $transition) {
if (!$transition['requires_comment'] && !$transition['requires_admin']) {
$reopenStatus = $transition['to_status'];
break;
}
}
}
if ($reopenStatus !== null) {
$ticketModel = new TicketModel($conn);
$ticketModel->updateTicket([
'ticket_id' => $existingId,
'title' => $title,
'description' => $description,
'category' => $category,
'type' => $type,
'status' => $reopenStatus,
'priority' => $priority,
], $userId);
} else {
error_log("create_ticket_api: hwmonDaemon recurrence for ticket $existingId"
. "no admin-free, comment-free transition from Closed is configured; leaving ticket Closed");
}
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
"hwmonDaemon detected this condition again. The ticket description reflects the "
. "original report; see this comment's timestamp for when the issue recurred.";
if ($reopenStatus === null) {
$commentText = "**Issue recurred, but the ticket could not be reopened automatically.**\n\n"
. "hwmonDaemon detected this condition again. No Workflow Designer transition from "
. "Closed is configured that this automation can perform unattended (no comment/admin "
. "requirement); the ticket remains Closed. Please review and reopen manually if appropriate.";
}
$commentStmt = $conn->prepare(
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
);
@@ -375,30 +393,40 @@ if ($existing) {
$commentStmt->execute();
$commentStmt->close();
if ($reopenStatus !== null) {
$auditLog->log($userId, 'update', 'ticket', $existingId, [
'status' => ['from' => 'Closed', 'to' => 'Open'],
'status' => ['from' => 'Closed', 'to' => $reopenStatus],
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
]);
// Ticket reopened (Closed → Open) — refresh dashboard stats.
// Ticket reopened — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
} else {
$auditLog->log($userId, 'update', 'ticket', $existingId, [
'reason' => 'hwmonDaemon recurrence detected but no valid reopen transition configured; ticket left Closed',
]);
}
$conn->close();
Database::close();
if ($reopenStatus !== null) {
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($existingId, [
'title' => $title,
'priority' => $priority,
'category' => $category,
'type' => $type,
'status' => 'Open',
'status' => $reopenStatus,
], 'automated');
}
echo json_encode([
'success' => true,
'ticket_id' => $existingId,
'message' => 'Existing closed ticket reopened',
'action' => 'reopened',
'message' => $reopenStatus !== null
? 'Existing closed ticket reopened'
: 'Recurrence noted; ticket left Closed (no valid workflow transition configured)',
'action' => $reopenStatus !== null ? 'reopened' : 'recurrence_noted',
]);
exit;
}
@@ -481,7 +509,7 @@ if ($inserted) {
// New ticket created — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
$conn->close();
Database::close();
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($ticket_id, [
+36 -1
View File
@@ -121,6 +121,33 @@ class CacheHelper
return $written;
}
/**
* Read the current invalidation epoch for a prefix (0 if never bumped).
* Used by remember() to detect an invalidation that happened while a
* cache-miss recomputation was in flight.
*/
private static function getEpoch(string $prefix): int
{
$safePrefix = preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix);
$file = self::getCacheDir() . '/' . $safePrefix . '.epoch';
$val = @file_get_contents($file);
return $val !== false ? (int)$val : 0;
}
/**
* Bump a prefix's invalidation epoch. Called whenever anything under the
* prefix is invalidated.
*/
private static function bumpEpoch(string $prefix): void
{
$safePrefix = preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix);
$file = self::getCacheDir() . '/' . $safePrefix . '.epoch';
$next = self::getEpoch($prefix) + 1;
if (@file_put_contents($file, (string)$next, LOCK_EX) !== false) {
@chmod($file, 0600);
}
}
/**
* Delete cached data
*
@@ -130,6 +157,8 @@ class CacheHelper
*/
public static function delete(string $prefix, $identifier = null): bool
{
self::bumpEpoch($prefix);
if ($identifier !== null) {
$key = self::makeKey($prefix, $identifier);
unset(self::$memoryCache[$key]);
@@ -192,8 +221,14 @@ class CacheHelper
$data = self::get($prefix, $identifier, $ttl);
if ($data === null) {
// Snapshot the epoch before running the (possibly slow) callback so
// a concurrent invalidation mid-computation can be detected below —
// otherwise this request's stale pre-invalidation result could
// overwrite a newer request's fresher write, extending staleness by
// up to another full TTL.
$epochBefore = self::getEpoch($prefix);
$data = $callback();
if ($data !== null) {
if ($data !== null && self::getEpoch($prefix) === $epochBefore) {
self::set($prefix, $identifier, $data);
}
}
+25 -4
View File
@@ -10,26 +10,36 @@ class ErrorHandler
{
private static ?string $logFile = null;
private static bool $initialized = false;
private static string $responseMode = 'json';
/**
* Initialize error handling
*
* @param bool $displayErrors Whether to display errors (false in production)
* @param string $responseMode 'json' (API endpoints) or 'html' (page views —
* renders views/error_500.php instead of a JSON body)
*/
public static function init(bool $displayErrors = false): void
public static function init(bool $displayErrors = false, string $responseMode = 'json'): void
{
if (self::$initialized) {
return;
}
self::$responseMode = $responseMode;
// Set error reporting
error_reporting(E_ALL);
ini_set('display_errors', $displayErrors ? '1' : '0');
ini_set('log_errors', '1');
// Set up log file
self::$logFile = sys_get_temp_dir() . '/tinker_tickets_errors.log';
ini_set('error_log', self::$logFile);
// Deliberately does NOT override the 'error_log' ini setting: doing so
// used to redirect every error_log() call in the request to a fixed
// /tmp file, silently diverting logs away from wherever the server is
// actually configured to send them (php-fpm's error_log, stdout in a
// container, etc.) the moment this got wired into more than one
// endpoint. self::$logFile / getRecentErrors() are unused (no callers
// app-wide) and exist only as an opt-in helper if something later
// wants a dedicated log file.
// Register handlers
set_error_handler([self::class, 'handleError']);
@@ -151,6 +161,17 @@ class ErrorHandler
{
http_response_code($httpCode);
if (self::$responseMode === 'html') {
if (!headers_sent()) {
header('Content-Type: text/html; charset=utf-8');
}
// Deliberately not passed $message/$exception — see error_500.php's
// docblock on why the fatal-error page must render with zero
// dependency on request-specific state.
include dirname(__DIR__) . '/views/error_500.php';
exit;
}
if (!headers_sent()) {
header('Content-Type: application/json');
}
+56 -16
View File
@@ -20,6 +20,12 @@ class NotificationHelper
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
// A slow-but-not-fully-hung hookshot endpoint could otherwise add up
// to the full CURLOPT_TIMEOUT per fire() call, and a single request
// can call fire() (via notifyWatchers/sendCommentNotification/etc.)
// more than once sequentially — capping just the connect phase keeps
// that from stacking into tens of seconds of added latency.
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
@@ -40,20 +46,40 @@ class NotificationHelper
return array_values(array_filter(array_map('trim', explode(',', $raw))));
}
/**
* Redact a ticket title for the shared Matrix notify list when the
* ticket isn't public, matching how sendCommentNotification() and
* notifyWatchers() already redact comment/activity previews for the
* same list.
*/
private static function redactedTitle(string $title, string $visibility): string
{
return $visibility === 'public' ? $title : '(restricted ticket — title hidden)';
}
// ─── Public event methods ─────────────────────────────────────────────────
/**
* New ticket created (manual or automated/API).
*
* $ticketData['visibility'] ('public', 'internal', or 'confidential') is
* used to redact the title sent to the shared MATRIX_NOTIFY_USERS list
* for non-public tickets, same as sendCommentNotification()'s preview
* redaction. Defaults to 'public' for callers (e.g. the hwmonDaemon
* Bearer-API paths) that never set a non-default visibility.
*/
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void
{
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
$visibility = $ticketData['visibility'] ?? 'public';
$title = $ticketData['title'] ?? 'Untitled';
preg_match('/^\[([^\]]+)\]/', $title, $m);
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
self::fire([
'event' => 'ticket_created',
'ticket_id' => $ticketId,
'title' => $ticketData['title'] ?? 'Untitled',
'title' => self::redactedTitle($title, $visibility),
'priority' => (int)($ticketData['priority'] ?? 4),
'category' => $ticketData['category'] ?? 'General',
'type' => $ticketData['type'] ?? 'Issue',
@@ -73,13 +99,16 @@ class NotificationHelper
* @param string $newStatus
* @param string $ticketTitle
* @param string|null $changedByDisplay Display name of the user who changed status
* @param string $visibility Ticket visibility; non-public titles are
* redacted before being sent to the shared
* notify list, same as sendTicketNotification().
*/
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null): void
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null, string $visibility = 'public'): void
{
self::fire([
'event' => 'status_changed',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'title' => self::redactedTitle($ticketTitle, $visibility),
'old_status' => $oldStatus,
'new_status' => $newStatus,
'changed_by' => $changedByDisplay,
@@ -166,11 +195,12 @@ class NotificationHelper
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
* @param int|null $excludeUserId Don't notify the actor themselves
* @param string $visibility Ticket visibility: 'public', 'internal', or
* 'confidential'. notify_users includes the
* shared list, which may contain users without
* access to non-public tickets, so any comment
* body preview in $extraData is redacted for
* non-public tickets.
* 'confidential'. The shared notify list may
* contain users without access to non-public
* tickets, so for those tickets it's excluded
* entirely (only actual watchers are notified)
* and both the title and any comment/body
* preview in $extraData are redacted.
*/
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null, string $visibility = 'public'): void
{
@@ -204,9 +234,9 @@ class NotificationHelper
return;
}
if ($excludeUserId !== null) {
$stmt->bind_param("ii", $ticketId, $excludeUserId);
$stmt->bind_param("si", $ticketId, $excludeUserId);
} else {
$stmt->bind_param("i", $ticketId);
$stmt->bind_param("s", $ticketId);
}
$stmt->execute();
$result = $stmt->get_result();
@@ -230,13 +260,17 @@ class NotificationHelper
return;
}
// Remove the global notify list duplicates and build payload
$allNotify = array_unique(array_merge($matrixIds, self::notifyUsers()));
// The shared notify list may include users without access to
// non-public tickets, so only mix it in for public tickets — for
// internal/confidential tickets, notify actual watchers only.
$allNotify = $visibility === 'public'
? array_unique(array_merge($matrixIds, self::notifyUsers()))
: $matrixIds;
$payload = array_merge($extraData, [
'event' => $event,
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'title' => self::redactedTitle($ticketTitle, $visibility),
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => array_values($allNotify),
]);
@@ -252,8 +286,14 @@ class NotificationHelper
* @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
* @param string $visibility Ticket visibility; non-public titles are
* redacted before being sent to the shared
* notify list, same as sendTicketNotification().
* The assignee is DMed directly regardless,
* since they now have standing access to the
* ticket by virtue of being assigned to it.
*/
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null): void
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null, string $visibility = 'public'): void
{
$notifyUsers = self::notifyUsers();
// Also notify the assignee directly if we know their Matrix ID
@@ -267,7 +307,7 @@ class NotificationHelper
self::fire([
'event' => 'assigned',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'title' => self::redactedTitle($ticketTitle, $visibility),
'assignee' => $assigneeName,
'changed_by' => $changedByDisplay,
'url' => UrlHelper::ticketUrl($ticketId),
-212
View File
@@ -1,212 +0,0 @@
<?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);
}
+19 -11
View File
@@ -1,10 +1,18 @@
<?php
// Main entry point for the application
// Registered first, before anything else, so a genuine fatal anywhere below
// (including during config.php's own env parsing) renders the styled 500
// page instead of falling through to PHP's raw default error handling.
require_once 'helpers/ErrorHandler.php';
ErrorHandler::init(false, 'html');
require_once 'config/config.php';
require_once 'middleware/SecurityHeadersMiddleware.php';
require_once 'middleware/AuthMiddleware.php';
require_once 'models/AuditLogModel.php';
require_once 'helpers/Database.php';
// Apply security headers early
SecurityHeadersMiddleware::apply();
@@ -17,15 +25,12 @@ $requestPath = strtok($request, '?');
// Create database connection for non-API routes
if (!str_starts_with($requestPath, '/api/')) {
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
die("Connection failed: " . $conn->connect_error);
try {
$conn = Database::getConnection();
} catch (\Throwable $e) {
error_log('index.php: database connection failed: ' . $e->getMessage());
http_response_code(500);
die('Sorry, something went wrong. Please try again shortly.');
}
// Authenticate user via Authelia forward auth
@@ -391,13 +396,16 @@ switch (true) {
LEFT JOIN (
SELECT user_id, MAX(created_at) as last_activity
FROM audit_log
WHERE DATE(created_at) BETWEEN ? AND ?
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',
'ssssssssss',
$dateRange['from'],
$dateRange['to'],
$dateRange['from'],
$dateRange['to'],
$dateRange['from'],
@@ -441,5 +449,5 @@ switch (true) {
// Close database connection if it was opened
if (isset($conn)) {
$conn->close();
Database::close();
}
+72
View File
@@ -92,6 +92,19 @@ class AuthMiddleware
} else {
// Update last activity time
$_SESSION['last_activity'] = time();
// Periodically re-validate Remote-User/Remote-Groups against
// current Authelia/LLDAP state, so a revoked admin (or anyone
// dropped from the required groups) loses access promptly
// instead of keeping it for up to SESSION_TIMEOUT. Only the
// idle timer was checked above; nothing previously re-read
// these headers once a session already existed.
$resyncInterval = $GLOBALS['config']['PRIVILEGE_RESYNC_INTERVAL'] ?? 300;
$lastSync = $_SESSION['last_privilege_sync'] ?? 0;
if (time() - $lastSync > $resyncInterval) {
$this->resyncPrivileges();
}
return $_SESSION['user'];
}
}
@@ -134,6 +147,7 @@ class AuthMiddleware
// Store user in session
$_SESSION['user'] = $user;
$_SESSION['last_activity'] = time();
$_SESSION['last_privilege_sync'] = time();
// Generate new CSRF token on login
require_once __DIR__ . '/CsrfMiddleware.php';
@@ -142,6 +156,64 @@ class AuthMiddleware
return $user;
}
/**
* Re-validate the current session's Remote-User/Remote-Groups against
* this request's forward-auth headers, and re-sync or revoke access on
* mismatch. Called periodically (PRIVILEGE_RESYNC_INTERVAL) from an
* already-authenticated session — see authenticate().
*
* Best-effort: if this particular request doesn't carry forward-auth
* headers at all (e.g. a proxy hiccup), the session is left as-is rather
* than force-logging the user out, and the check is simply retried on
* the next request past the interval.
*/
private function resyncPrivileges(): void
{
$username = $this->getHeader('HTTP_REMOTE_USER');
$groups = $this->getHeader('HTTP_REMOTE_GROUPS');
if (empty($username)) {
return;
}
$this->enforceTrustedProxy();
// A different Remote-User than the session's own means Authelia is
// now asserting a different identity entirely for this proxy path;
// don't silently relabel the session as that other user.
if ($username !== ($_SESSION['user']['username'] ?? null)) {
return;
}
if (!$this->checkGroupAccess($groups)) {
$this->logSecurityEvent('privilege_resync_revoked', [
'username' => $username,
'groups' => $groups ?: 'none',
]);
session_unset();
session_destroy();
$this->redirectToAuth();
exit;
}
$displayName = $this->getHeader('HTTP_REMOTE_NAME');
$email = $this->getHeader('HTTP_REMOTE_EMAIL');
// Bypass UserModel's 5-minute in-process cache — that cache key isn't
// group-aware, so a stale cached hit here would silently keep serving
// the pre-revocation is_admin value for the rest of the cache's TTL.
UserModel::invalidateCache(null, $username);
$user = $this->userModel->syncUserFromAuthelia($username, $displayName, $email, $groups);
$wasAdmin = !empty($_SESSION['user']['is_admin']);
if ($wasAdmin && empty($user['is_admin'])) {
$this->logSecurityEvent('privilege_resync_admin_revoked', ['username' => $username]);
}
$_SESSION['user'] = $user;
$_SESSION['last_privilege_sync'] = time();
}
/**
* Reject forward-auth headers that did not arrive via a trusted proxy.
*
+128 -20
View File
@@ -3,21 +3,34 @@
/**
* Rate Limiting Middleware
*
* Implements both session-based and IP-based rate limiting to prevent abuse.
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
* Implements session-based, IP-based, and (for Bearer-authenticated
* requests) API-key-based rate limiting to prevent abuse.
* IP-based limiting prevents attackers from bypassing limits by creating new
* sessions; API-key-based limiting keeps distinct Bearer clients from
* starving each other's shared IP bucket.
*/
class RateLimitMiddleware
{
// Default limits
// Fallback limits, used only if $GLOBALS['config'] isn't populated
// (e.g. very early in bootstrap, or a test harness). Normal requests read
// RATE_LIMIT_DEFAULT/RATE_LIMIT_API from config (backed by .env).
public const DEFAULT_LIMIT = 100; // requests per window (session)
public const API_LIMIT = 60; // API requests per window (session)
public const IP_LIMIT = 300; // IP-based requests per window (more generous)
public const IP_API_LIMIT = 120; // IP-based API requests per window
public const API_KEY_LIMIT = 120; // Per-Bearer-token requests per window
public const WINDOW_SECONDS = 60; // 1 minute window
// Directory for IP rate limit storage
private static ?string $rateLimitDir = null;
private static function sessionLimit(string $type): int
{
$configKey = $type === 'api' ? 'RATE_LIMIT_API' : 'RATE_LIMIT_DEFAULT';
$fallback = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
return (int)($GLOBALS['config'][$configKey] ?? $fallback);
}
/**
* Get the rate limit storage directory
*
@@ -69,24 +82,47 @@ class RateLimitMiddleware
}
/**
* Check IP-based rate limit
* Extract the raw Bearer token from the Authorization header, if present.
* Deliberately independent of ApiKeyAuth: rate limiting must be cheap and
* must not require a DB round-trip to validate the key before counting
* the request, and needs to run whether or not the token turns out to be
* valid. The raw token string (not the validated api_key_id) is hashed as
* the bucket identifier — good enough to isolate distinct keys/clients
* from each other without needing to authenticate first.
*
* @param string $type 'default' or 'api'
* @return bool True if request is allowed, false if rate limited
* @return string|null
*/
private static function checkIpRateLimit(string $type = 'default'): bool
private static function getBearerToken(): ?string
{
$ip = self::getClientIp();
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
$now = time();
$header = $_SERVER['HTTP_AUTHORIZATION']
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
?? null;
if ($header === null && function_exists('getallheaders')) {
$headers = getallheaders();
$header = $headers['Authorization'] ?? null;
}
if ($header && preg_match('/^Bearer\s+(.+)$/i', $header, $m)) {
return $m[1];
}
return null;
}
// Create a hash of the IP for the filename (security + filesystem safety)
$ipHash = hash('sha256', $ip . '_' . $type);
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
/**
* Generic file-based sliding-window counter, shared by the IP-based and
* API-key-based buckets below.
*
* @param string $bucketKey Stable identifier for this bucket (already hashed)
* @param int $limit Max requests allowed per window
* @return bool True if this request is within the limit
*/
private static function checkCounter(string $bucketKey, int $limit): bool
{
$now = time();
$filePath = self::getRateLimitDir() . '/' . $bucketKey . '.json';
// Hold an exclusive lock across the whole read-modify-write so concurrent
// requests from the same IP can't both read the same count and each write
// count+1 (which would undercount and let the limit be exceeded).
// requests from the same bucket can't both read the same count and each
// write count+1 (which would undercount and let the limit be exceeded).
$fh = @fopen($filePath, 'c+');
if ($fh === false) {
// Can't open the counter file — fail open (don't block legitimate traffic).
@@ -122,10 +158,60 @@ class RateLimitMiddleware
flock($fh, LOCK_UN);
fclose($fh);
// Check if over limit
return $rateData['count'] <= $limit;
}
/**
* Read (without incrementing) the current state of a counter bucket, for
* status/header reporting.
*/
private static function peekCounter(string $bucketKey, int $limit): array
{
$now = time();
$filePath = self::getRateLimitDir() . '/' . $bucketKey . '.json';
$rateData = null;
$content = @file_get_contents($filePath);
if ($content !== false && $content !== '') {
$decoded = json_decode($content, true);
if (is_array($decoded)) {
$rateData = $decoded;
}
}
if ($rateData === null || $now - ($rateData['window_start'] ?? $now) >= self::WINDOW_SECONDS) {
return ['limit' => $limit, 'remaining' => $limit, 'reset' => $now + self::WINDOW_SECONDS];
}
return [
'limit' => $limit,
'remaining' => max(0, $limit - $rateData['count']),
'reset' => $rateData['window_start'] + self::WINDOW_SECONDS,
];
}
private static function ipBucketKey(string $type): string
{
return hash('sha256', self::getClientIp() . '_' . $type);
}
private static function apiKeyBucketKey(string $token): string
{
return hash('sha256', 'apikey_' . $token);
}
/**
* Check IP-based rate limit
*
* @param string $type 'default' or 'api'
* @return bool True if request is allowed, false if rate limited
*/
private static function checkIpRateLimit(string $type = 'default'): bool
{
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
return self::checkCounter(self::ipBucketKey($type), $limit);
}
/**
* Clean up old rate limit files (call periodically)
*
@@ -185,7 +271,14 @@ class RateLimitMiddleware
}
/**
* Check rate limit for current request (both session and IP)
* Check rate limit for current request.
*
* Bearer-authenticated requests (Authorization: Bearer ...) are limited
* by a per-token bucket instead of a session — a stateless API client
* never sends a session cookie back, so the session-based counter never
* accumulates and starting a session for it is pure overhead. The
* IP-based bucket still applies underneath as defense-in-depth against
* volumetric abuse from one network path.
*
* @param string $type 'default' or 'api'
* @return bool True if request is allowed, false if rate limited
@@ -197,12 +290,17 @@ class RateLimitMiddleware
return false;
}
$token = self::getBearerToken();
if ($token !== null) {
return self::checkCounter(self::apiKeyBucketKey($token), self::API_KEY_LIMIT);
}
// Then check session-based rate limit
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
$limit = self::sessionLimit($type);
$key = 'rate_limit_' . $type;
$now = time();
@@ -270,18 +368,28 @@ class RateLimitMiddleware
}
/**
* Get current rate limit status
* Get current rate limit status.
*
* For a Bearer-authenticated request, reports the per-API-key bucket
* (the one that actually governs it) rather than the session-based
* counter, which is meaningless for a client that never sends a session
* cookie back.
*
* @param string $type 'default' or 'api'
* @return array Rate limit status
*/
public static function getStatus(string $type = 'default'): array
{
$token = self::getBearerToken();
if ($token !== null) {
return self::peekCounter(self::apiKeyBucketKey($token), self::API_KEY_LIMIT);
}
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
$limit = self::sessionLimit($type);
$key = 'rate_limit_' . $type;
$now = time();
+8 -7
View File
@@ -57,7 +57,7 @@ CREATE TABLE IF NOT EXISTS `bulk_operations` (
`operation_id` int(11) NOT NULL AUTO_INCREMENT,
`operation_type` varchar(50) NOT NULL,
`ticket_ids` text NOT NULL,
`performed_by` int(11) NOT NULL,
`performed_by` int(11) DEFAULT NULL,
`parameters` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`parameters`)),
-- 32, not 20: 'completed_with_errors' is 21 chars (see 001_widen_bulk_operations_status.sql)
`status` varchar(32) DEFAULT 'pending',
@@ -69,7 +69,7 @@ CREATE TABLE IF NOT EXISTS `bulk_operations` (
PRIMARY KEY (`operation_id`),
KEY `idx_performed_by` (`performed_by`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `bulk_operations_ibfk_1` FOREIGN KEY (`performed_by`) REFERENCES `users` (`user_id`)
CONSTRAINT `bulk_operations_ibfk_1` FOREIGN KEY (`performed_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ custom_field_definitions ============
@@ -155,7 +155,7 @@ CREATE TABLE IF NOT EXISTS `saved_filters` (
UNIQUE KEY `unique_user_filter_name` (`user_id`,`filter_name`),
KEY `idx_user_filters` (`user_id`,`is_default`),
CONSTRAINT `saved_filters_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ status_transitions ============
CREATE TABLE IF NOT EXISTS `status_transitions` (
@@ -185,7 +185,7 @@ CREATE TABLE IF NOT EXISTS `ticket_attachments` (
KEY `idx_attachments_ticket` (`ticket_id`),
KEY `idx_attachments_uploaded_by` (`uploaded_by`),
CONSTRAINT `ticket_attachments_ibfk_1` FOREIGN KEY (`uploaded_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ ticket_comments ============
CREATE TABLE IF NOT EXISTS `ticket_comments` (
@@ -238,16 +238,17 @@ CREATE TABLE IF NOT EXISTS `ticket_templates` (
PRIMARY KEY (`template_id`),
KEY `created_by` (`created_by`),
KEY `idx_template_name` (`template_name`),
CONSTRAINT `ticket_templates_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`)
CONSTRAINT `ticket_templates_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ ticket_watchers ============
CREATE TABLE IF NOT EXISTS `ticket_watchers` (
`ticket_id` int(11) NOT NULL,
`ticket_id` varchar(9) NOT NULL,
`user_id` int(11) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`ticket_id`,`user_id`),
KEY `idx_watcher_user` (`user_id`)
KEY `idx_watcher_user` (`user_id`),
CONSTRAINT `fk_watchers_ticket_id` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`ticket_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ============ tickets ============
@@ -0,0 +1,30 @@
-- Fix collation inconsistency on saved_filters and ticket_attachments
--
-- README.md Developer Notes #12: "Database collation: Use
-- utf8mb4_general_ci (not unicode_ci) for new tables." These two tables
-- were created with utf8mb4_unicode_ci instead, inconsistent with every
-- other table in the schema. Mixed collations don't break anything by
-- themselves, but any future query joining/comparing these columns
-- against general_ci columns needs explicit COLLATE casts or hits
-- "Illegal mix of collations" errors.
--
-- Safe to re-run.
ALTER TABLE `saved_filters`
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
-- saved_filters.filter_criteria is pinned to utf8mb4_bin (for the
-- json_valid() CHECK constraint) — restore that after the table-wide
-- CONVERT TO above, which resets it to general_ci. MariaDB drops the
-- inline CHECK when the column is MODIFYed, so re-add it explicitly.
ALTER TABLE `saved_filters`
MODIFY COLUMN `filter_criteria` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL;
ALTER TABLE `saved_filters`
DROP CONSTRAINT IF EXISTS `saved_filters_filter_criteria_json`;
ALTER TABLE `saved_filters`
ADD CONSTRAINT `saved_filters_filter_criteria_json` CHECK (json_valid(`filter_criteria`));
ALTER TABLE `ticket_attachments`
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
+32
View File
@@ -0,0 +1,32 @@
-- Fix inconsistent FK ON DELETE behavior on bulk_operations.performed_by and
-- ticket_templates.created_by
--
-- Every other user-reference FK in the schema (tickets.created_by/updated_by/
-- assigned_to, ticket_attachments.uploaded_by, ticket_dependencies.created_by,
-- recurring_tickets.created_by/assigned_to, api_keys.created_by, etc.) uses
-- ON DELETE SET NULL. These two had no ON DELETE clause at all, which
-- defaults to RESTRICT — so deleting a user who ever ran a bulk operation or
-- created a template hard-fails at the DB level instead of nulling the
-- reference, breaking the pattern used everywhere else and potentially
-- blocking legitimate user offboarding/cleanup.
--
-- bulk_operations.performed_by is NOT NULL today; it must become nullable to
-- support SET NULL, matching how every other SET NULL column in the schema
-- is defined.
--
-- Safe to re-run.
ALTER TABLE `bulk_operations`
MODIFY COLUMN `performed_by` int(11) DEFAULT NULL;
ALTER TABLE `bulk_operations`
DROP FOREIGN KEY IF EXISTS `bulk_operations_ibfk_1`;
ALTER TABLE `bulk_operations`
ADD CONSTRAINT `bulk_operations_ibfk_1` FOREIGN KEY (`performed_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL;
ALTER TABLE `ticket_templates`
DROP FOREIGN KEY IF EXISTS `ticket_templates_ibfk_1`;
ALTER TABLE `ticket_templates`
ADD CONSTRAINT `ticket_templates_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL;
@@ -0,0 +1,28 @@
-- Fix ticket_watchers.ticket_id type mismatch and missing FK to tickets
--
-- ticket_watchers.ticket_id was int(11), while every other satellite table
-- (ticket_comments, ticket_attachments, ticket_dependencies,
-- custom_field_values) stores it as varchar(9)/varchar(10) matching
-- tickets.ticket_id. There was also no FK constraint at all, unlike every
-- other satellite table, so orphaned watcher rows could never be caught by
-- referential integrity. Ticket IDs are always 9-digit numeric strings
-- (see TicketModel::create's sprintf('%09d', ...)), so the int -> varchar(9)
-- conversion below is lossless for real data.
--
-- Safe to re-run.
-- Remove any watcher rows that no longer point at a real ticket (possible
-- today precisely because there was no FK to prevent it) before adding the
-- constraint, since orphans would make the ADD CONSTRAINT below fail.
DELETE tw FROM `ticket_watchers` tw
LEFT JOIN `tickets` t ON tw.`ticket_id` = t.`ticket_id`
WHERE t.`ticket_id` IS NULL;
ALTER TABLE `ticket_watchers`
MODIFY COLUMN `ticket_id` varchar(9) NOT NULL;
ALTER TABLE `ticket_watchers`
DROP FOREIGN KEY IF EXISTS `fk_watchers_ticket_id`;
ALTER TABLE `ticket_watchers`
ADD CONSTRAINT `fk_watchers_ticket_id` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`ticket_id`) ON DELETE CASCADE;
+68 -11
View File
@@ -46,6 +46,23 @@ if (!$conn->query($createTable)) {
exit(1);
}
// Tracks per-statement progress within a migration file. MySQL DDL statements
// (ALTER/CREATE TABLE, etc.) cause an implicit commit, so begin_transaction()/
// rollback() around a whole file can't actually undo DDL already executed
// earlier in that same file. This table lets a re-run after a partial failure
// resume from the statement after the last one that succeeded, instead of
// re-executing already-applied DDL and wedging on "already exists" errors.
$createProgressTable = "CREATE TABLE IF NOT EXISTS migration_progress (
filename VARCHAR(255) NOT NULL PRIMARY KEY,
last_statement_index INT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)";
if (!$conn->query($createProgressTable)) {
echo "Error: Could not create migration_progress table: " . $conn->error . "\n";
exit(1);
}
// Get list of completed migrations
$completed = [];
$result = $conn->query("SELECT filename FROM migrations ORDER BY id");
@@ -114,47 +131,87 @@ foreach ($pending as $file) {
continue;
}
// Execute migration - handle multiple statements
$conn->begin_transaction();
// Execute migration statement-by-statement, tracking progress as we go.
// No begin_transaction()/rollback() here: DDL statements auto-commit in
// MySQL/MariaDB regardless, so a transaction wrapper around the whole
// file would only create the illusion of atomicity while giving no real
// protection. Instead, each statement commits immediately (autocommit),
// and its index is durably recorded so a later re-run can resume exactly
// where a previous run left off rather than re-executing already-applied
// DDL.
try {
// Split by semicolon but respect statements properly
// Note: This doesn't handle semicolons in strings, but our migrations are simple
$statements = array_filter(
$statements = array_values(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));
}
);
));
$resumeFrom = 0;
$progressStmt = $conn->prepare(
"SELECT last_statement_index FROM migration_progress WHERE filename = ?"
);
$progressStmt->bind_param('s', $filename);
$progressStmt->execute();
$progressRow = $progressStmt->get_result()->fetch_assoc();
$progressStmt->close();
if ($progressRow) {
$resumeFrom = (int)$progressRow['last_statement_index'] + 1;
echo "\n Resuming from statement " . ($resumeFrom + 1) . " of " . count($statements)
. " after a previous partial failure... ";
}
foreach ($statements as $index => $statement) {
if ($index < $resumeFrom) {
continue;
}
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;
}
} else {
throw new Exception($error);
}
}
// Record the migration
// Record progress after every statement so a later run can
// resume from here even if a subsequent statement fails.
$upsert = $conn->prepare(
"INSERT INTO migration_progress (filename, last_statement_index) VALUES (?, ?)
ON DUPLICATE KEY UPDATE last_statement_index = VALUES(last_statement_index)"
);
$upsert->bind_param('si', $filename, $index);
$upsert->execute();
$upsert->close();
}
// Record the migration as fully complete and clear its progress marker
$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();
$clearProgress = $conn->prepare("DELETE FROM migration_progress WHERE filename = ?");
$clearProgress->bind_param('s', $filename);
$clearProgress->execute();
$clearProgress->close();
echo "OK\n";
$success++;
} catch (Exception $e) {
$conn->rollback();
// Nothing to roll back: every statement up to the failure already
// committed (DDL implicitly, everything else via autocommit). The
// progress marker recorded above reflects exactly how far this file
// got, so the next run will resume right after the last success.
echo "FAILED (" . $e->getMessage() . ")\n";
$failed++;
}
+9 -1
View File
@@ -16,7 +16,7 @@ class AttachmentModel
/**
* Get all attachments for a ticket
*/
public function getAttachments($ticketId)
public function getAttachments($ticketId, int $limit = 0, int $offset = 0)
{
$sql = "SELECT a.*, u.username, u.display_name
FROM ticket_attachments a
@@ -24,8 +24,16 @@ class AttachmentModel
WHERE a.ticket_id = ?
ORDER BY a.uploaded_at DESC";
if ($limit > 0) {
$sql .= " LIMIT ? OFFSET ?";
}
$stmt = $this->conn->prepare($sql);
if ($limit > 0) {
$stmt->bind_param("sii", $ticketId, $limit, $offset);
} else {
$stmt->bind_param("s", $ticketId);
}
$stmt->execute();
$result = $stmt->get_result();
+16 -5
View File
@@ -309,17 +309,28 @@ class AuditLogModel
* @param int $daysToKeep Number of days of logs to keep
* @return int Number of deleted records
*/
public function deleteOldLogs($daysToKeep = 90)
public function deleteOldLogs($daysToKeep = 90, $batchSize = 1000)
{
// Batched to bound how long each statement holds row locks — an
// unbounded single DELETE on a large backlog (e.g. the first run after
// enabling/changing retention, or after the cron silently missed runs)
// would otherwise contend with the frequent concurrent INSERTs the
// audit log receives from live traffic.
$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) ORDER BY audit_id LIMIT ?"
);
$stmt->bind_param("i", $daysToKeep);
$stmt->bind_param("ii", $daysToKeep, $batchSize);
$totalDeleted = 0;
do {
$stmt->execute();
$affectedRows = $stmt->affected_rows;
$affected = $stmt->affected_rows;
$totalDeleted += $affected;
} while ($affected > 0);
$stmt->close();
return $affectedRows;
return $totalDeleted;
}
/**
+61 -2
View File
@@ -125,13 +125,23 @@ class BulkOperationsModel
$processed = 0;
$failed = 0;
$errors = [];
// Status-change notifications collected during the loop below and
// sent only after a successful commit, matching how the single-ticket
// and Bearer API paths never notify for a change that didn't durably
// land (and how an atomic-mode rollback must not fire any at all).
$notificationQueue = [];
// Load required models
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
$ticketModel = new TicketModel($this->conn);
$auditLogModel = new AuditLogModel($this->conn);
$userModel = new UserModel($this->conn);
$actor = $operation['performed_by'] ? $userModel->getUserById((int)$operation['performed_by']) : null;
$changedByDisplay = $actor['display_name'] ?? $actor['username'] ?? null;
// Batch load all tickets in one query to eliminate N+1 problem
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
@@ -221,8 +231,18 @@ class BulkOperationsModel
'update',
'ticket',
$ticketId,
['status' => 'Closed', 'bulk_operation_id' => $operationId]
[
'status' => ['from' => $currentTicket['status'], 'to' => 'Closed'],
'bulk_operation_id' => $operationId,
]
);
$notificationQueue[] = [
'ticketId' => $ticketId,
'title' => $currentTicket['title'],
'visibility' => $currentTicket['visibility'] ?? 'public',
'oldStatus' => $currentTicket['status'],
'newStatus' => 'Closed',
];
}
}
break;
@@ -291,8 +311,18 @@ class BulkOperationsModel
'update',
'ticket',
$ticketId,
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]
[
'status' => ['from' => $currentTicket['status'], 'to' => $parameters['status']],
'bulk_operation_id' => $operationId,
]
);
$notificationQueue[] = [
'ticketId' => $ticketId,
'title' => $currentTicket['title'],
'visibility' => $currentTicket['visibility'] ?? 'public',
'oldStatus' => $currentTicket['status'],
'newStatus' => $parameters['status'],
];
}
}
}
@@ -364,6 +394,35 @@ class BulkOperationsModel
@unlink($path);
}
}
// Fire the same Matrix/watcher notifications the single-ticket and
// Bearer API status-change paths send, now that every change in
// this batch is durably committed. Best-effort: a notification
// failure must never turn an otherwise-successful bulk operation
// into an error.
foreach ($notificationQueue as $n) {
try {
NotificationHelper::sendStatusChangeNotification(
$n['ticketId'],
$n['oldStatus'],
$n['newStatus'],
$n['title'],
$changedByDisplay,
$n['visibility']
);
NotificationHelper::notifyWatchers(
$this->conn,
$n['ticketId'],
$n['title'],
'status_changed',
['old_status' => $n['oldStatus'], 'new_status' => $n['newStatus'], 'changed_by' => $changedByDisplay],
(int)$operation['performed_by'],
$n['visibility']
);
} catch (Throwable $e) {
error_log("Bulk operation $operationId: notification failed for ticket {$n['ticketId']}: " . $e->getMessage());
}
}
} catch (Exception $e) {
// Rollback on any unexpected error
$this->conn->rollback();
+1 -1
View File
@@ -38,7 +38,7 @@ class CommentModel
}
$placeholders = str_repeat('?,', count($usernames) - 1) . '?';
$sql = "SELECT user_id, username, display_name FROM users WHERE username IN ($placeholders)";
$sql = "SELECT user_id, username, display_name, is_admin, `groups` FROM users WHERE username IN ($placeholders)";
$stmt = $this->conn->prepare($sql);
$types = str_repeat('s', count($usernames));
+11
View File
@@ -8,6 +8,9 @@ class CustomFieldModel
{
private $conn;
// Must match custom_field_definitions.field_type's enum() in the schema.
private const ALLOWED_FIELD_TYPES = ['text', 'textarea', 'select', 'checkbox', 'date', 'number'];
public function __construct($conn)
{
$this->conn = $conn;
@@ -87,6 +90,10 @@ class CustomFieldModel
*/
public function createDefinition($data)
{
if (!in_array($data['field_type'] ?? '', self::ALLOWED_FIELD_TYPES, true)) {
return ['success' => false, 'error' => 'Invalid field_type'];
}
$options = null;
if (isset($data['field_options']) && !empty($data['field_options'])) {
$options = json_encode($data['field_options']);
@@ -129,6 +136,10 @@ class CustomFieldModel
*/
public function updateDefinition($fieldId, $data)
{
if (!in_array($data['field_type'] ?? '', self::ALLOWED_FIELD_TYPES, true)) {
return ['success' => false, 'error' => 'Invalid field_type'];
}
$options = null;
if (isset($data['field_options']) && !empty($data['field_options'])) {
$options = json_encode($data['field_options']);
+21
View File
@@ -172,6 +172,27 @@ class DependencyModel
}
$checkStmt->close();
// Also check the semantic inverse: "A blocks B" and "B blocked_by A"
// describe the same relationship, so adding one from either ticket's
// page must be rejected as a duplicate of the other. relates_to is
// its own inverse (symmetric); duplicates has no defined inverse type.
$inverseTypes = ['blocks' => 'blocked_by', 'blocked_by' => 'blocks', 'relates_to' => 'relates_to'];
if (isset($inverseTypes[$type])) {
$inverseType = $inverseTypes[$type];
$checkInverseSql = "SELECT dependency_id FROM ticket_dependencies
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
$checkInverseStmt = $this->conn->prepare($checkInverseSql);
$checkInverseStmt->bind_param("sss", $dependsOnId, $ticketId, $inverseType);
$checkInverseStmt->execute();
$inverseResult = $checkInverseStmt->get_result();
if ($inverseResult->num_rows > 0) {
$checkInverseStmt->close();
return ['success' => false, 'error' => 'This relationship already exists'];
}
$checkInverseStmt->close();
}
// Check for circular dependency
if ($this->wouldCreateCycle($ticketId, $dependsOnId, $type)) {
return ['success' => false, 'error' => 'This would create a circular dependency'];
+26 -26
View File
@@ -189,30 +189,6 @@ class RecurringTicketModel
return $claimed;
}
/**
* Update last run and calculate next run time
*/
public function updateAfterRun($recurringId)
{
$recurring = $this->getById($recurringId);
if (!$recurring) {
return false;
}
$nextRun = $this->calculateNextRunTime(
$recurring['schedule_type'],
$recurring['schedule_day'],
$recurring['schedule_time']
);
$sql = "UPDATE recurring_tickets SET last_run_at = NOW(), next_run_at = ? WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('si', $nextRun, $recurringId);
$success = $stmt->execute();
$stmt->close();
return $success;
}
/**
* Calculate the next run time based on schedule
*/
@@ -255,9 +231,33 @@ class RecurringTicketModel
*/
public function toggleActive($recurringId)
{
$sql = "UPDATE recurring_tickets SET is_active = NOT is_active WHERE recurring_id = ?";
$recurring = $this->getById($recurringId);
if (!$recurring) {
return ['success' => false];
}
$newActive = $recurring['is_active'] ? 0 : 1;
if ($newActive) {
// Re-enabling: recompute next_run_at from now, as if the schedule
// were freshly created. Otherwise a schedule paused while
// next_run_at was still in the future, then re-enabled after that
// date has passed, would fire immediately on the next cron tick
// instead of waiting for its next natural occurrence.
$nextRun = $this->calculateNextRunTime(
$recurring['schedule_type'],
$recurring['schedule_day'],
$recurring['schedule_time']
);
$sql = "UPDATE recurring_tickets SET is_active = ?, next_run_at = ? WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $recurringId);
$stmt->bind_param('isi', $newActive, $nextRun, $recurringId);
} else {
$sql = "UPDATE recurring_tickets SET is_active = ? WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('ii', $newActive, $recurringId);
}
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
+63 -1
View File
@@ -726,7 +726,10 @@ class TicketModel
$groupConditions = [];
foreach ($userGroups as $group) {
$groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))";
$params[] = $group;
// Strip spaces from the bound value too, matching the REPLACE()
// applied to the column, so a group name like "IT Support" is
// normalized the same way on both sides of the comparison.
$params[] = str_replace(' ', '', $group);
$types .= 's';
}
$conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))";
@@ -770,9 +773,68 @@ class TicketModel
$stmt->bind_param("ssis", $visibility, $visibilityGroups, $updatedBy, $ticketId);
$result = $stmt->execute();
$stmt->close();
if ($result) {
$this->pruneWatchersForVisibility($ticketId, $visibility, $visibilityGroups);
}
return $result;
}
/**
* Remove any watchers who no longer qualify for a ticket's access rules
* after its visibility was tightened. Without this, a user watching a
* ticket that's later made confidential/internal (and who isn't
* creator/assignee/admin/in the new visibility_groups) keeps receiving
* Matrix notifications about a ticket canUserAccessTicket() would now
* reject them from opening directly.
*/
private function pruneWatchersForVisibility(string $ticketId, string $visibility, ?string $visibilityGroups): void
{
$ticket = $this->getTicketById($ticketId);
if (!$ticket) {
return;
}
// getTicketById() reflects the just-committed UPDATE, but set these
// explicitly so pruning is correct even if a caller reorders things.
$ticket['visibility'] = $visibility;
$ticket['visibility_groups'] = $visibilityGroups;
$sql = "SELECT tw.user_id, u.is_admin, u.`groups`
FROM ticket_watchers tw
JOIN users u ON tw.user_id = u.user_id
WHERE tw.ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('s', $ticketId);
$stmt->execute();
$watchers = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
$toRemove = [];
foreach ($watchers as $watcher) {
$watcherUser = [
'user_id' => $watcher['user_id'],
'is_admin' => $watcher['is_admin'],
'groups' => $watcher['groups'],
];
if (!$this->canUserAccessTicket($ticket, $watcherUser)) {
$toRemove[] = $watcher['user_id'];
}
}
if (empty($toRemove)) {
return;
}
$placeholders = implode(',', array_fill(0, count($toRemove), '?'));
$delSql = "DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id IN ($placeholders)";
$delStmt = $this->conn->prepare($delSql);
$types = 's' . str_repeat('i', count($toRemove));
$delStmt->bind_param($types, $ticketId, ...$toRemove);
$delStmt->execute();
$delStmt->close();
}
/**
* Delete a ticket and all its associated records.
* Admin-only operation. Removes comments, attachments, watchers, dependencies.
+18 -7
View File
@@ -98,19 +98,30 @@ class UserModel
$user['groups'] = $groups;
$user['is_admin'] = $isAdmin;
} else {
// Create new user
// Create new user. Uses INSERT ... ON DUPLICATE KEY UPDATE (rather than
// a plain INSERT) so two concurrent first-visit requests for the same
// brand-new username can't race: the losing request updates the row the
// winner just created instead of throwing an uncaught duplicate-key
// exception (users.username has a UNIQUE KEY, and mysqli throws on
// constraint violation under PHP 8.1+'s default report mode).
$insertStmt = $this->conn->prepare(
"INSERT INTO users (username, display_name, email, `groups`, is_admin, last_login) VALUES (?, ?, ?, ?, ?, NOW())"
"INSERT INTO users (username, display_name, email, `groups`, is_admin, last_login)
VALUES (?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name),
email = VALUES(email),
`groups` = VALUES(groups),
is_admin = VALUES(is_admin),
last_login = NOW()"
);
$insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin);
$insertStmt->execute();
$userId = $this->conn->insert_id;
$insertStmt->close();
// Get the newly created user
$stmt = $this->conn->prepare("SELECT * FROM users WHERE user_id = ?");
$stmt->bind_param("i", $userId);
// Re-fetch by username — works whether this request won the insert or
// lost the race and only updated the winner's row.
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = ?");
$stmt->bind_param("s", $username);
$stmt->execute();
$result = $stmt->get_result();
$user = $result->fetch_assoc();
+47
View File
@@ -10,9 +10,30 @@
* Usage: php scripts/check_requirements.php
*/
/**
* Parse a php.ini size value (e.g. "128M", "1G", "-1") into bytes.
* Returns -1 for unlimited.
*/
function parseIniBytes(string $val): int
{
$val = trim($val);
if ($val === '' || $val === '-1') {
return -1;
}
$unit = strtolower(substr($val, -1));
$num = (int)$val;
return match ($unit) {
'g' => $num * 1024 * 1024 * 1024,
'm' => $num * 1024 * 1024,
'k' => $num * 1024,
default => $num,
};
}
$req = require __DIR__ . '/../config/requirements.php';
$errors = [];
$warnings = [];
// PHP version
$minPhp = $req['min_php_version'];
@@ -27,6 +48,28 @@ foreach ($req['required_extensions'] as $ext) {
}
}
// memory_limit / max_execution_time sanity checks (warnings, not hard
// failures — see config/requirements.php for why these matter).
$memLimitIni = ini_get('memory_limit');
$memLimitBytes = parseIniBytes($memLimitIni);
$minMemBytes = $req['min_memory_limit_mb'] * 1024 * 1024;
if ($memLimitBytes !== -1 && $memLimitBytes < $minMemBytes) {
$warnings[] = sprintf(
'memory_limit is %s, below the recommended minimum %dM',
$memLimitIni,
$req['min_memory_limit_mb']
);
}
$maxExecTime = (int)ini_get('max_execution_time');
if ($maxExecTime !== 0 && $maxExecTime < $req['min_max_execution_time']) {
$warnings[] = sprintf(
'max_execution_time is %ds, below the recommended minimum %ds',
$maxExecTime,
$req['min_max_execution_time']
);
}
if (!empty($errors)) {
fwrite(STDERR, "Requirement check FAILED:\n");
foreach ($errors as $err) {
@@ -35,6 +78,10 @@ if (!empty($errors)) {
exit(1);
}
foreach ($warnings as $warn) {
fwrite(STDERR, "Requirement check WARNING: " . $warn . "\n");
}
printf(
"Requirement check passed: PHP %s (>= %s); extensions: %s\n",
PHP_VERSION,
+74 -3
View File
@@ -124,7 +124,7 @@ include __DIR__ . '/layout_header.php';
<div class="lt-form-group">
<label class="lt-label" for="category">Category</label>
<select id="category" name="category" class="lt-select">
<select id="category" name="category" class="lt-select" data-action="toggle-custom-fields">
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
@@ -211,6 +211,55 @@ include __DIR__ . '/layout_header.php';
</div>
</div>
<?php if (!empty($allCustomFieldDefs)) : ?>
<!-- ── SECTION 5b: Custom Fields ─────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Additional Fields</div>
<div class="lt-section-body">
<?php foreach ($allCustomFieldDefs as $cfDef) : ?>
<div class="lt-form-group custom-field-group"
data-custom-field-category="<?= htmlspecialchars($cfDef['category'] ?? '', ENT_QUOTES, 'UTF-8') ?>">
<?php
$cfName = 'custom_fields[' . (int)$cfDef['field_id'] . ']';
$cfId = 'custom_field_' . (int)$cfDef['field_id'];
?>
<label class="lt-label" for="<?= $cfId ?>">
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?><?= $cfDef['is_required'] ? ' *' : '' ?>
</label>
<?php if ($cfDef['field_type'] === 'textarea') : ?>
<textarea id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input lt-textarea" rows="3"
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>></textarea>
<?php elseif ($cfDef['field_type'] === 'select') : ?>
<select id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-select"
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
<option value=""> Select </option>
<?php foreach (($cfDef['field_options']['options'] ?? []) as $opt) : ?>
<option value="<?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach ?>
</select>
<?php elseif ($cfDef['field_type'] === 'checkbox') : ?>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="<?= $cfId ?>" name="<?= $cfName ?>" value="1">
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?>
</label>
<?php elseif ($cfDef['field_type'] === 'date') : ?>
<input type="date" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
<?php elseif ($cfDef['field_type'] === 'number') : ?>
<input type="number" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
<?php else : ?>
<input type="text" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
<?php endif ?>
</div>
<?php endforeach ?>
<p class="lt-form-hint">Fields shown depend on the selected Category.</p>
</div>
</div>
<?php endif ?>
<!-- ── SECTION 6: Description ───────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
@@ -316,6 +365,15 @@ include __DIR__ . '/layout_header.php';
.catch(function () { /* silent — duplicate check is non-critical */ });
}
// ── Custom fields: show only the selected category's fields ──
function toggleCustomFields() {
var category = document.getElementById('category').value;
document.querySelectorAll('.custom-field-group').forEach(function (group) {
var fieldCategory = group.getAttribute('data-custom-field-category');
group.classList.toggle('is-hidden', fieldCategory !== '' && fieldCategory !== category);
});
}
// ── Visibility groups toggle ──────────────────────────────
var visibilityHints = {
'public': 'Everyone who is logged in can view this ticket.',
@@ -344,12 +402,23 @@ include __DIR__ . '/layout_header.php';
var existingTitle = (document.getElementById('title').value || '').trim();
var existingDesc = (document.getElementById('description').value || '').trim();
if (existingTitle || existingDesc) {
if (!confirm('Applying this template will overwrite your current title and description. Continue?')) {
document.getElementById('templateSelect').value = '';
showConfirmModal(
'Overwrite content?',
'Applying this template will overwrite your current title and description. Continue?',
'warning',
applyTemplate,
function () { document.getElementById('templateSelect').value = ''; }
);
return;
}
applyTemplate();
}
function applyTemplate() {
var tplId = document.getElementById('templateSelect').value;
if (!tplId) return;
lt.api.get('/api/get_template.php?template_id=' + encodeURIComponent(tplId))
.then(function (data) {
if (!data.success || !data.template) {
@@ -376,9 +445,11 @@ include __DIR__ . '/layout_header.php';
switch (target.getAttribute('data-action')) {
case 'load-template': loadTemplate(); break;
case 'toggle-visibility-groups': toggleVisibilityGroups(); break;
case 'toggle-custom-fields': toggleCustomFields(); break;
}
});
toggleCustomFields();
if (window.lt) lt.keys.initDefaults();
}());
</script>
+82 -9
View File
@@ -120,7 +120,6 @@ include __DIR__ . '/layout_header.php';
?>
<div class="lt-stat-card stat-open" role="button" tabindex="0"
data-filter-key="status" data-filter-val="Open,Pending,In Progress"
title="Click to filter by active tickets" aria-label="Open tickets">
<div class="lt-stat-icon">[ # ]</div>
<div class="lt-stat-info">
@@ -133,7 +132,6 @@ include __DIR__ . '/layout_header.php';
</div>
<div class="lt-stat-card stat-critical" role="button" tabindex="0"
data-filter-key="priority" data-filter-val="1"
title="Click to filter critical (P1) tickets" aria-label="Critical P1 tickets">
<div class="lt-stat-icon lt-text-danger">[ ! ]</div>
<div class="lt-stat-info">
@@ -146,7 +144,6 @@ include __DIR__ . '/layout_header.php';
</div>
<div class="lt-stat-card stat-unassigned" role="button" tabindex="0"
data-filter-key="assigned_to" data-filter-val="unassigned"
title="Click to filter unassigned tickets" aria-label="Unassigned tickets">
<div class="lt-stat-icon lt-text-amber">[ @ ]</div>
<div class="lt-stat-info">
@@ -171,7 +168,6 @@ include __DIR__ . '/layout_header.php';
</div>
<div class="lt-stat-card stat-resolved" role="button" tabindex="0"
data-filter-key="status" data-filter-val="Closed"
title="Click to filter closed tickets" aria-label="Closed tickets today">
<div class="lt-stat-icon lt-text-muted">[ OK ]</div>
<div class="lt-stat-info">
@@ -277,9 +273,66 @@ include __DIR__ . '/layout_header.php';
array_values($stats['by_category'] ?? [])
))) ?>;
// ── Click-to-filter ────────────────────────────────────────────────────────
// Charts navigate to the same URL filters the stat cards use.
//
// The status the click filters on has to be explicit rather than left to the
// default: with no `status` param the controller falls back to the viewer's
// default_status_filters preference, which can be anything, so the resulting
// list would not necessarily match what the chart counted. StatsModel builds
// by_priority and by_category with `status != 'Closed'`, while by_status spans
// every status — so only the priority and category charts pin the open set.
function openStatuses() {
var all = window.TICKET_STATUSES || ['Open', 'Pending', 'In Progress', 'Closed'];
return all.filter(function(s) { return s !== 'Closed'; }).join(',');
}
function gotoFilter(params) {
var qs = new URLSearchParams(window.location.search);
Object.keys(params).forEach(function(k) {
if (params[k] !== null && params[k] !== undefined && params[k] !== '') qs.set(k, params[k]);
else qs.delete(k);
});
window.location.href = '/?' + qs.toString();
}
// Each chart maps a clicked label to a filter. Returns null when the label
// can't be mapped, so the click is simply ignored.
var CHART_FILTERS = {
chartPriority: function(label) {
var m = /^P(\d+)$/.exec(label);
return m ? { priority: m[1], status: openStatuses() } : null;
},
chartStatus: function(label) {
return label ? { status: label } : null;
},
chartCategory: function(label) {
return label ? { category: label, status: openStatuses() } : null;
}
};
function filterOnClick(canvasId) {
return function(evt, elements, chart) {
if (!elements || !elements.length) return;
var label = chart.data.labels[elements[0].index];
var mapper = CHART_FILTERS[canvasId];
var params = mapper && mapper(label);
if (params) gotoFilter(params);
};
}
// Pointer cursor over clickable segments so the affordance is visible.
function filterOnHover(evt, elements) {
if (evt && evt.native && evt.native.target) {
evt.native.target.style.cursor = (elements && elements.length) ? 'pointer' : 'default';
}
}
function makeDonut(canvasId, data, colorMap) {
var ctx = document.getElementById(canvasId);
if (!ctx || !data.length) return;
if (!ctx) return;
if (!data.length) { showChartEmptyState(ctx); return; }
ctx.title = 'Click a segment to filter the ticket list';
return new Chart(ctx, {
type: 'doughnut',
data: {
@@ -295,21 +348,37 @@ include __DIR__ . '/layout_header.php';
},
options: {
responsive: true, maintainAspectRatio: false,
onClick: filterOnClick(canvasId),
onHover: filterOnHover,
plugins: {
legend: {
position: 'bottom',
labels: { color: '#8fa3b1', font: { family: 'monospace', size: 10 }, padding: 8, boxWidth: 10 }
},
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.label + ': ' + ctx.parsed; } } }
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.label + ': ' + ctx.parsed + ' — click to filter'; } } }
},
cutout: '68%'
}
});
}
function showChartEmptyState(canvas) {
canvas.style.display = 'none';
var wrap = canvas.parentElement;
if (wrap && !wrap.querySelector('.lt-chart-empty')) {
var msg = document.createElement('div');
msg.className = 'lt-chart-empty';
msg.style.cssText = 'display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:0.75rem';
msg.textContent = 'No data for current filters';
wrap.appendChild(msg);
}
}
function makeBar(canvasId, data) {
var ctx = document.getElementById(canvasId);
if (!ctx || !data.length) return;
if (!ctx) return;
if (!data.length) { showChartEmptyState(ctx); return; }
ctx.title = 'Click a bar to filter the ticket list';
return new Chart(ctx, {
type: 'bar',
data: {
@@ -323,7 +392,12 @@ include __DIR__ . '/layout_header.php';
},
options: {
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
onClick: filterOnClick(canvasId),
onHover: filterOnHover,
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.parsed.x + ' — click to filter'; } } }
},
scales: {
x: { ticks: { color: '#8fa3b1', font: { size: 10 } }, grid: { color: 'rgba(0,255,65,0.06)' } },
y: { ticks: { color: '#8fa3b1', font: { family: 'monospace', size: 10 } }, grid: { display: false } }
@@ -1162,7 +1236,6 @@ window.TICKET_STATUSES = <?= json_encode($GLOBALS['config']['TICKET_STATUSES'])
if (window.lt) {
lt.keys.initDefaults();
lt.tableNav.init('tickets-table');
lt.statsFilter.init();
}
// Saved filter pills — load on page init
+114 -96
View File
@@ -114,6 +114,7 @@ $json_priority = json_encode($ticket['priority'], JSON_HEX_TAG);
$json_category = json_encode($ticket['category'], JSON_HEX_TAG);
$json_type = json_encode($ticket['type'], JSON_HEX_TAG);
$json_updated_at = json_encode($ticket['updated_at'], JSON_HEX_TAG);
$json_created_at_ts = json_encode((int)strtotime($ticket['created_at']), JSON_HEX_TAG);
$json_total_comments = json_encode((int)$totalComments, JSON_HEX_TAG);
$json_comment_page = json_encode((int)$commentPageSize, JSON_HEX_TAG);
$json_current_uid = json_encode((int)($currentUser['user_id'] ?? 0), JSON_HEX_TAG);
@@ -127,6 +128,7 @@ window.ticketData = {
category: {$json_category},
type: {$json_type},
updated_at: {$json_updated_at},
created_at_ts: {$json_created_at_ts},
totalComments: {$json_total_comments},
commentOffset: {$json_comment_page},
commentPageSize:{$json_comment_page},
@@ -209,95 +211,17 @@ include __DIR__ . '/layout_header.php';
</div>
</div>
<?php if ($priorityNum <= 2 && $ticket['status'] !== 'Closed') : ?>
<?php
$slaTargetHours = match ($priorityNum) {
1 => 8, 2 => 24, default => 72
};
$elapsedSeconds = time() - strtotime($ticket['created_at']);
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
$slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600);
$slaClass = $priorityNum === 1 ? 'lt-sla-p1' : 'lt-sla-p2';
$slaIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
$slaLabel = $priorityNum === 1 ? 'P1 Critical' : 'P2 High';
$slaId = 'sla-' . htmlspecialchars($ticket['ticket_id'], ENT_QUOTES, 'UTF-8');
?>
<!-- SLA banner P1/P2 only, dismissible per session -->
<div class="<?= $slaClass ?>" id="priorityAlertBanner" role="alert" aria-live="polite"
data-sla-id="<?= $slaId ?>"
data-created-at="<?= (int)strtotime($ticket['created_at']) ?>"
data-sla-hours="<?= $slaTargetHours ?>"
style="margin-bottom:0.75rem">
<span class="lt-sla-icon" aria-hidden="true"><?= $slaIcon ?></span>
<div class="lt-sla-info">
<div class="lt-sla-title">
<?= $slaLabel ?> — SLA: <span id="slaElapsedTimer"></span> elapsed of <?= $slaTargetHours ?>h limit
<?php if ($slaBreached) : ?>
&nbsp;<span class="lt-text-danger" id="slaBreachLabel">BREACHED</span>
<?php endif ?>
</div>
<div class="lt-sla-bar" aria-label="SLA progress <?= $slaPct ?>%" id="slaProgress">
<div class="lt-sla-fill" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div>
</div>
</div>
<?php if (!$slaBreached) : ?>
<div class="lt-sla-meta" id="slaCountdownTimer"></div>
<?php else : ?>
<div class="lt-sla-meta lt-text-danger" id="slaCountdownTimer">+<span id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</span> over</div>
<?php endif ?>
<button type="button" class="lt-sla-dismiss" aria-label="Dismiss">&#x2715;</button>
</div>
<?php // SLA banner (P1/P2, non-Closed tickets) is rendered and kept live by
// renderSlaBanner() in ticket.js, so it can also rebuild/tear itself
// down when priority changes client-side without a page reload. ?>
<div id="priorityAlertBannerAnchor"></div>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
(function(){
var banner = document.getElementById('priorityAlertBanner');
var id = banner.dataset.slaId;
try { if (id && sessionStorage.getItem('lt_sla_dismissed_' + id)) banner.hidden = true; } catch(e) {}
banner.querySelector('.lt-sla-dismiss').addEventListener('click', function() {
banner.hidden = true;
try { if (id) sessionStorage.setItem('lt_sla_dismissed_' + id, '1'); } catch(e) {}
});
document.addEventListener('DOMContentLoaded', function() {
if (banner.hidden) return;
var createdAt = parseInt(banner.dataset.createdAt, 10) * 1000;
var slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
var deadline = new Date(createdAt + slaMs);
var elapsedEl = document.getElementById('slaElapsedTimer');
var countdownEl = document.getElementById('slaCountdownTimer');
var overrunEl = document.getElementById('slaOverrunTimer');
var fillBar = document.getElementById('slaProgressBar');
var progressWrap = document.getElementById('slaProgress');
function fmtHMS(ms) {
var s = Math.floor(Math.abs(ms) / 1000);
var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
return [h, m, ss].map(function(n){ return String(n).padStart(2,'0'); }).join(':');
document.addEventListener('DOMContentLoaded', function() {
if (typeof renderSlaBanner === 'function') {
renderSlaBanner(window.ticketData.priority);
}
function tick() {
var now = Date.now();
var elapsed = now - createdAt;
var remaining = deadline - now;
var pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
if (fillBar) fillBar.style.width = pct + '%';
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
if (remaining > 0) {
if (countdownEl) countdownEl.textContent = fmtHMS(remaining) + ' remaining';
} else {
if (overrunEl) overrunEl.textContent = fmtHMS(-remaining);
}
}
tick();
setInterval(tick, 1000);
});
})();
});
</script>
<?php endif ?>
<!-- ═══════════════════════════════════════════════════════════
TICKET DETAIL FRAME
@@ -473,6 +397,12 @@ include __DIR__ . '/layout_header.php';
role="tab" data-tab="dependencies-panel" aria-selected="false" aria-controls="dependencies-panel">
Dependencies
</button>
<?php if (!empty($customFieldDefs)) : ?>
<button type="button" class="lt-tab" id="custom-fields-tab-btn"
role="tab" data-tab="custom-fields-panel" aria-selected="false" aria-controls="custom-fields-panel">
Custom Fields
</button>
<?php endif ?>
<button type="button" class="lt-tab" id="activity-tab-btn"
role="tab" data-tab="activity-panel" aria-selected="false" aria-controls="activity-panel">
Activity
@@ -758,6 +688,60 @@ include __DIR__ . '/layout_header.php';
</div>
</div>
<?php if (!empty($customFieldDefs)) : ?>
<!-- ═══════════════════════════════════════════════════════════
TAB PANEL: CUSTOM FIELDS
═══════════════════════════════════════════════════════════ -->
<div id="custom-fields-panel" class="lt-tab-panel" role="tabpanel" aria-labelledby="custom-fields-tab-btn">
<div class="lt-frame">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header">Custom Fields</div>
<div class="lt-section-body">
<div id="customFieldsMsg" class="lt-msg is-hidden lt-mb-md" role="alert" aria-live="polite"></div>
<?php foreach ($customFieldDefs as $cfDef) :
$cfValue = $customFieldValues[$cfDef['field_name']]['field_value'] ?? '';
$cfName = 'custom_fields[' . (int)$cfDef['field_id'] . ']';
$cfId = 'ticket_custom_field_' . (int)$cfDef['field_id'];
?>
<div class="lt-form-group">
<label class="lt-label" for="<?= $cfId ?>">
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?><?= $cfDef['is_required'] ? ' *' : '' ?>
</label>
<?php if ($cfDef['field_type'] === 'textarea') : ?>
<textarea id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input lt-textarea" rows="3"
><?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?></textarea>
<?php elseif ($cfDef['field_type'] === 'select') : ?>
<select id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-select">
<option value=""> Select </option>
<?php foreach (($cfDef['field_options']['options'] ?? []) as $opt) : ?>
<option value="<?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?>"
<?= $opt === $cfValue ? 'selected' : '' ?>><?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach ?>
</select>
<?php elseif ($cfDef['field_type'] === 'checkbox') : ?>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="<?= $cfId ?>" name="<?= $cfName ?>" value="1"
<?= $cfValue === '1' ? 'checked' : '' ?>>
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?>
</label>
<?php elseif ($cfDef['field_type'] === 'date') : ?>
<input type="date" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
value="<?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?>">
<?php elseif ($cfDef['field_type'] === 'number') : ?>
<input type="number" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
value="<?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?>">
<?php else : ?>
<input type="text" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
value="<?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?>">
<?php endif ?>
</div>
<?php endforeach ?>
<button type="button" id="saveCustomFieldsBtn" class="lt-btn lt-btn-primary lt-btn-sm">SAVE CUSTOM FIELDS</button>
</div>
</div>
</div>
<?php endif ?>
<!-- ═══════════════════════════════════════════════════════════
TAB PANEL: ACTIVITY
═══════════════════════════════════════════════════════════ -->
@@ -1006,9 +990,7 @@ document.addEventListener('DOMContentLoaded', function () {
shown.forEach(function (w) {
var words = (w.display_name || '').trim().split(/\s+/).filter(Boolean);
var initials = words.slice(0, 2).map(function (x) { return x[0].toUpperCase(); }).join('');
var hash = 0;
for (var i = 0; i < (w.display_name || '').length; i++) hash = ((hash << 5) - hash + (w.display_name || '').charCodeAt(i)) | 0;
var color = avatarColors[Math.abs(hash) % 4];
var color = avatarColors[crc32(w.display_name || '') % 4];
html += '<div class="lt-avatar lt-avatar--xs ' + color + '" title="' + lt.escHtml(w.display_name) + '" aria-label="' + lt.escHtml(w.display_name) + '">' +
'<img src="/api/user_avatar.php?user_id=' + w.user_id + '" alt="" class="lt-avatar-img">' +
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
@@ -1091,6 +1073,46 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
// Save custom fields button
var saveCustomFieldsBtn = document.getElementById('saveCustomFieldsBtn');
if (saveCustomFieldsBtn) {
saveCustomFieldsBtn.addEventListener('click', function () {
var panel = document.getElementById('custom-fields-panel');
var msg = document.getElementById('customFieldsMsg');
var values = {};
panel.querySelectorAll('[name^="custom_fields["]').forEach(function (el) {
var m = el.name.match(/custom_fields\[(\d+)\]/);
if (!m) return;
var fieldId = m[1];
if (el.type === 'checkbox') {
values[fieldId] = el.checked ? '1' : '0';
} else {
values[fieldId] = el.value;
}
});
saveCustomFieldsBtn.disabled = true;
msg.classList.add('is-hidden');
lt.api.post('/api/ticket_custom_fields.php', {
ticket_id: window.ticketData.id,
values: values
}).then(function (data) {
saveCustomFieldsBtn.disabled = false;
if (data.success) {
lt.toast.success('Custom fields saved', 3000);
} else {
msg.textContent = data.error || 'Failed to save custom fields';
msg.className = 'lt-msg lt-msg-danger lt-mb-md';
}
}).catch(function (error) {
saveCustomFieldsBtn.disabled = false;
msg.textContent = 'Failed to save custom fields: ' + error.message;
msg.className = 'lt-msg lt-msg-danger lt-mb-md';
});
});
}
// Settings save/cancel
// Load user preference toggles on settings modal open
(function() {
@@ -1219,7 +1241,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (typeof parseMarkdown === 'function') {
list.querySelectorAll('.comment-text[data-markdown]').forEach(function (el) {
if (!el.dataset.rendered) {
el.innerHTML = parseMarkdown(el.textContent);
el.innerHTML = parseMarkdown(el.textContent.trim());
el.dataset.rendered = '1';
}
});
@@ -1252,13 +1274,9 @@ document.addEventListener('DOMContentLoaded', function () {
var words = displayName.trim().split(/\s+/).filter(Boolean);
var initials = words.slice(0, 2).map(function (w) { return w[0].toUpperCase(); }).join('');
// Avatar color (same modulo logic as PHP: crc32 mod 4)
// Avatar color (real crc32, matching PHP's crc32 % 4 exactly)
var avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
var hash = 0;
for (var i = 0; i < displayName.length; i++) {
hash = ((hash << 5) - hash + displayName.charCodeAt(i)) | 0;
}
var avatarColor = avatarColors[Math.abs(hash) % 4];
var avatarColor = avatarColors[crc32(displayName) % 4];
// Format date
var dateStr = c.created_at || '';
+38
View File
@@ -0,0 +1,38 @@
<?php
/**
* Standalone 500/fatal-error page, rendered by ErrorHandler for page-view
* (non-API) requests.
*
* Deliberately self-contained: a genuine fatal can happen before config.php
* finishes loading, mid-session-start, or mid-DB-query, so this view must
* not depend on $GLOBALS['config'], $GLOBALS['currentUser'], a session, or a
* DB connection being available/working. It links the static base.css
* stylesheet (served directly by the webserver, independent of PHP) to
* match the app's look without going through layout_header.php's app-state
* dependent setup.
*/
?>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>500 &mdash; Something Went Wrong</title>
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="/assets/css/base.css">
</head>
<body>
<div class="lt-frame" style="max-width:32rem;margin:4rem auto">
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="lt-section-header lt-text-danger">[ 500 ] SOMETHING WENT WRONG</div>
<div class="lt-section-body lt-text-center">
<p class="lt-text-muted lt-mb-md">
An unexpected error occurred. It's been logged; please try again shortly.
</p>
<a href="/" class="lt-btn lt-btn-primary">&larr; Dashboard</a>
</div>
</div>
</body>
</html>
+31 -10
View File
@@ -235,11 +235,11 @@
}
function loadNotifications() {
fetch('/api/notifications.php', { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(renderNotifications)
return lt.api.get('/api/notifications.php')
.then(function(data) { renderNotifications(data); return true; })
.catch(function() {
list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>';
return false;
});
}
@@ -250,20 +250,41 @@
if (clearBtn) {
clearBtn.addEventListener('click', function() {
fetch('/api/notifications.php', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN || '' },
body: JSON.stringify({ action: 'mark_read' })
}).then(loadNotifications);
lt.api.post('/api/notifications.php', { action: 'mark_read' }).then(loadNotifications);
});
}
document.addEventListener('click', function(e) { if (_open && wrapEl && !wrapEl.contains(e.target)) closePanel(); });
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && _open) closePanel(); });
// Initial badge count + poll every 60s
// Poll every 60s while the tab is visible, backing off (up to 5 min) on
// repeated failures, and resuming immediately when the tab regains focus.
var POLL_INTERVAL = 60000;
var MAX_POLL_INTERVAL = 300000;
var _pollTimer = null;
var _failCount = 0;
function scheduleNextPoll(delay) {
clearTimeout(_pollTimer);
_pollTimer = setTimeout(pollNotifications, delay);
}
function pollNotifications() {
if (document.hidden) return;
loadNotifications().then(function(ok) {
_failCount = ok ? 0 : _failCount + 1;
var delay = ok ? POLL_INTERVAL : Math.min(POLL_INTERVAL * Math.pow(2, _failCount), MAX_POLL_INTERVAL);
scheduleNextPoll(delay);
});
}
document.addEventListener('visibilitychange', function() {
if (!document.hidden) pollNotifications();
});
// Initial badge count, then start the poll cycle
loadNotifications();
setInterval(loadNotifications, 60000);
scheduleNextPoll(POLL_INTERVAL);
})();
<?php endif ?>