Compare commits

...
Author SHA1 Message Date
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
62 changed files with 1598 additions and 732 deletions
+63 -37
View File
@@ -1,60 +1,86 @@
# 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
+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
+12 -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';
@@ -177,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;
}
}
+16 -5
View File
@@ -138,7 +138,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 != ?
@@ -225,10 +225,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');
+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';
+2 -2
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';
+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');
+20 -15
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);
$action = $data['action'] ?? '';
$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);
+95
View File
@@ -2475,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()),
+17 -85
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
@@ -1135,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">
@@ -1166,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; }
});
}
@@ -1215,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();
+229 -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];
}
/**
@@ -432,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)
*/
@@ -455,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);
}
}
})
@@ -1007,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 => {
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
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';
@@ -1048,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)}">
@@ -1075,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 });
+7
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',
+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
];
+92 -64
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();
$auditLog->log($userId, 'update', 'ticket', $existingId, [
'status' => ['from' => 'Closed', 'to' => 'Open'],
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
]);
if ($reopenStatus !== null) {
$auditLog->log($userId, 'update', 'ticket', $existingId, [
'status' => ['from' => 'Closed', 'to' => $reopenStatus],
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
]);
// Ticket reopened (Closed → Open) — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
// 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();
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($existingId, [
'title' => $title,
'priority' => $priority,
'category' => $category,
'type' => $type,
'status' => 'Open',
], 'automated');
if ($reopenStatus !== null) {
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($existingId, [
'title' => $title,
'priority' => $priority,
'category' => $category,
'type' => $type,
'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);
}
}
+26 -5
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 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');
}
+50 -16
View File
@@ -40,20 +40,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 +93,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 +189,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 +228,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 +254,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 +280,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 +301,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.
*
+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);
}
throw new Exception($error);
}
// 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
// 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++;
}
+10 -2
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);
$stmt->bind_param("s", $ticketId);
if ($limit > 0) {
$stmt->bind_param("sii", $ticketId, $limit, $offset);
} else {
$stmt->bind_param("s", $ticketId);
}
$stmt->execute();
$result = $stmt->get_result();
+17 -6
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->execute();
$affectedRows = $stmt->affected_rows;
$stmt->bind_param("ii", $daysToKeep, $batchSize);
$totalDeleted = 0;
do {
$stmt->execute();
$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));
+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'];
+27 -27
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 = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $recurringId);
$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('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];
+4 -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) . "))";
+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,
+15 -4
View File
@@ -344,12 +344,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 = '';
return;
}
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) {
+18 -8
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">
@@ -292,9 +288,10 @@ include __DIR__ . '/layout_header.php';
}
function gotoFilter(params) {
var qs = new URLSearchParams();
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();
}
@@ -333,7 +330,8 @@ include __DIR__ . '/layout_header.php';
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',
@@ -364,9 +362,22 @@ include __DIR__ . '/layout_header.php';
});
}
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',
@@ -1225,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
+13 -95
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
@@ -1006,9 +930,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>' +
@@ -1252,13 +1174,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>
+30 -4
View File
@@ -235,11 +235,12 @@
}
function loadNotifications() {
fetch('/api/notifications.php', { credentials: 'same-origin' })
return fetch('/api/notifications.php', { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(renderNotifications)
.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;
});
}
@@ -261,9 +262,34 @@
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 ?>