Compare commits

...
Author SHA1 Message Date
jaredandClaude Opus 5.5 dad066cb32 Merge development into main: MCP server documentation (#111)
Lint / PHP (phpcs PSR-12) (push) Successful in 21s
Lint / JS (eslint) (push) Successful in 13s
Lint / PHP requirements (version + extensions) (push) Successful in 49s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m55s
Lint / Deploy (push) Successful in 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 19:26:42 -04:00
jaredandClaude Opus 5.5 9c633aa09b Document the MCP server: connecting Claude Code, tools, architecture (#111)
Lint / PHP (phpcs PSR-12) (push) Successful in 42s
Lint / JS (eslint) (push) Successful in 16s
Lint / PHP requirements (version + extensions) (push) Successful in 42s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m46s
Lint / Deploy (push) Successful in 2s
Adds a README section on connecting Claude Code to /mcp (the pre-registered
client id + fixed callback port Authelia requires, `claude mcp login`
incl. --no-browser for headless hosts), the six tools and their scopes,
and how the pieces fit together: Authelia as the authorization server
(and the 4.39.21/22 RFC 8707 bug to avoid), token validation incl. the
per-server audience, Composer being MCP-only, and the proxy exemptions.
Also updates the project-structure tree (mcp/, services/,
ApiTicketController, composer files, migrations 005-007) and notes that
uploads/ is only served via PHP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 19:26:40 -04:00
jaredandClaude Opus 5.5 9bbe4ef9a0 Merge development into main: OAuth-protected remote MCP server (#111)
Lint / PHP (phpcs PSR-12) (push) Successful in 19s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 22s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m34s
Lint / Deploy (push) Successful in 3s
Adds /mcp, a remote MCP server (official MCP PHP SDK, pinned 0.8.1)
protected by Authelia-issued OAuth access tokens, so Claude Code can work
with Tinker Tickets as the signed-in user: search_tickets, get_ticket,
create_ticket, add_comment, update_status, assign_ticket. Tools run the
same code paths as the web UI (ApiTicketController moved to its own
file; CommentService, AssignmentService and TicketCreationService
extracted from their web endpoints). Composer is introduced for the MCP
endpoint only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 19:21:30 -04:00
jaredandClaude Opus 5.5 17be55bf8f Add MCP write tools: create_ticket, add_comment, update_status, assign_ticket (#111, phase 5)
Lint / PHP (phpcs PSR-12) (push) Successful in 20s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m5s
Lint / Deploy (push) Successful in 2s
Each tool is a thin adapter over the same code path the web UI uses
(TicketCreationService, CommentService, ApiTicketController,
AssignmentService), run as the signed-in user, so permissions, Workflow
Designer rules, audit entries, notifications and stats-cache
invalidation are identical to doing the same thing in the browser.

- Registered in ToolCatalog and listed in its WRITE_TOOLS, so
  ToolScopeMiddleware requires tickets:write for them.
- Annotated readOnlyHint=false / destructiveHint=false (nothing deletes).
- Input the web form constrains with dropdowns (priority 1-5, visibility,
  status) is validated in the tools. Assignees are "me", a username or
  "unassigned".
- A ticket the user can't see reads as "not found" (never "access
  denied"), consistent with get_ticket.
- update_status turns requires_comment into an actionable error and
  invalidates the stats cache like api/update_ticket.php does.

Verified locally through the real pipeline (only JWT validation stubbed)
against MariaDB with seeded workflow transitions: 30/30 checks, including
a read-only token getting 403 insufficient_scope on create_ticket with
nothing written; create/comment/status/assign attributed and
audit-logged as the user; @mentions; internal visibility needing groups
and staying hidden from non-members; an invisible confidential ticket
not found for comment/status; requires_comment enforced, and closing
with a reason persisted in one transaction; transitions outside the
workflow refused; the admin/creator/assignee rule for assigning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 19:13:41 -04:00
jaredandClaude Opus 5.5 f206bb5889 Extract ticket creation from TicketController into TicketCreationService (#111)
Required-field and required-custom-field validation, createTicket
(which enforces visibility rules), the audit log, stats-cache
invalidation, custom field values, the optional 'duplicates' link and
the new-ticket notification move into services/TicketCreationService.php
with the same order and error messages, so the MCP create_ticket tool
runs one code path with the web form. TicketController::create keeps
CSRF, the redirect, and re-rendering the form on error (the view never
read the removed locals). The service also accepts visibility_groups as
a string for API callers; the controller still passes only the form's
array, so web behaviour is unchanged.

Verified the real web form over HTTP (logged in via Remote-User, real
CSRF token): a valid submit redirects to the new ticket, created and
audit-logged as the user; a blank description re-renders the form with
'Description is required' and creates nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 19:13:31 -04:00
jaredandClaude Opus 5.5 9e462f7f00 Extract ticket assignment from assign_ticket.php into AssignmentService (#111)
The access check, the admin/creator/current-assignee permission rule,
unassign/assign, the audit log, the optional Matrix assignment
notification and the stats-cache invalidation move into
services/AssignmentService.php for reuse by the MCP assign_ticket tool.
Error messages and status codes are unchanged.

One deliberate difference: assign_ticket.php's early error responses
(400/403/404) used a bare echo and so omitted the CSRF token that
bootstrap had just rotated. Every response now goes through
apiRespond(), which includes it. The front end already resyncs its
token from any response body, so this is compatible, and a failed
assign can no longer leave the page holding a stale token.

Verified over real HTTP: the assignee can reassign (200); a user who can
see the ticket but isn't admin/creator/assignee gets 403 'Permission
denied'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 19:13:31 -04:00
jaredandClaude Opus 5.5 d5832fb58a Extract comment creation from add_comment.php into CommentService (#111)
Validation, the ticket access check, reply-parent validation, @mention
extraction (audit-logged, notified only to mentioned users who can see
the ticket), and comment/watcher notifications move into
services/CommentService.php, so the MCP add_comment tool runs one code
path with the web UI. add_comment.php keeps session, CSRF, JSON parsing
and response codes. Error messages and status codes are unchanged; the
extracted body diffs against the original only where each 'emit error
and exit' became a 'return [..., http_status]'.

Verified the web endpoint over real HTTP: a comment is trimmed, saved
with its @mention and the rotated CSRF token returned; a confidential
ticket the user can't see still gets 403 'Access denied'; empty text
still gets 400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 19:13:31 -04:00
jaredandClaude Opus 5.5 6ce3380d6a Move ApiTicketController out of update_ticket.php into its own file (#111)
The partial-update controller (status transitions incl. requires_comment
with the comment in the same transaction, field edits, visibility,
audit delta, status-change notifications) was defined inline inside
api/update_ticket.php, so nothing else could reuse it. Moved it verbatim
to controllers/ApiTicketController.php so the MCP update_status tool can
run the exact same code path as the web UI; update_ticket.php now just
require_once's it. The class body is byte-identical to the original
(diffed against HEAD, modulo the 4-space dedent).

Verified the web endpoint over real HTTP with a real session + CSRF:
Open -> In Progress succeeds, In Progress -> Closed without a comment is
refused with requires_comment (400), and with a comment closes the
ticket and persists the reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 19:13:31 -04:00
jaredandClaude Opus 5.5 65deedc295 Add MCP identity mapping and read tools (#111, phase 4)
Lint / PHP (phpcs PSR-12) (push) Successful in 32s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 22s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m4s
Lint / Deploy (push) Successful in 2s
MCP requests now run as the signed-in Tinker Tickets user, under exactly
the same rules as the web login, and expose the first two tools:
search_tickets and get_ticket.

Identity:
- IdentityMiddleware maps the validated token to a user with the same
  checks as AuthMiddleware: the admin/employee group rule, now extracted
  into helpers/AccessPolicy.php so both entry points share one copy, then
  UserModel::syncUserFromAuthelia(), which creates/updates the row and
  derives is_admin from groups.
- Claims are read from the validated token's server-side PSR-7 request
  attributes, not from JSON-RPC _meta. The SDK's OAuthRequestMetaMiddleware
  is deliberately not used: it array_merges into client-writable _meta,
  so only the keys the validator happens to set are overwritten and a
  client could inject others.

Scopes (ToolScopeMiddleware), enforced before dispatch:
- lifecycle messages need only a valid token; write tools (listed in
  ToolCatalog, the single registry) need tickets:write; everything else
  needs tickets:read, which tickets:write implies.
- Denials are the spec's step-up challenge: 403 +
  WWW-Authenticate: Bearer error="insufficient_scope", scope=...,
  resource_metadata=...

Tools (read-only, annotated readOnlyHint):
- search_tickets: text/status/priority/category/assignee ("me",
  "unassigned", or a username), paginated, via TicketModel::getAllTickets
  with the user's visibility filter. Defaults to every non-Closed status.
- get_ticket: details + comments, gated by canUserAccessTicket. A missing
  ticket and a non-visible one return the same "not found".

Verified locally against a real MariaDB fixture (public, confidential,
internal+group, and closed tickets across two users), driving the real
pipeline (ToolCatalog, both middlewares, SDK transport) with only JWT
validation stubbed: 20/20 checks pass, including visibility parity per
user, confidential tickets hidden from non-owners, the group check
rejecting a user without admin/employee, a missing preferred_username
rejected, 403 insufficient_scope for a token without tickets:*, and
write implying read. Also exercised the stateless 2026-07-28 era (no
session, _meta + MCP-Protocol-Version/Mcp-Method/Mcp-Name headers), which
returns the same visibility-filtered results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 18:58:30 -04:00
jaredandClaude Opus 5.5 13660b4398 Fix duplicated Host header rejecting all MCP requests under PHP-FPM (#111)
Lint / PHP (phpcs PSR-12) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP requirements (version + extensions) (push) Successful in 21s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m8s
Lint / Deploy (push) Successful in 2s
nyholm's ServerRequestCreator adds Host both from the URI it builds and
from the request headers, so under PHP-FPM getHeaderLine('Host') returns
"beta.t.lotusguild.org, beta.t.lotusguild.org". The SDK's DNS-rebinding
check compares that joined string against the allowlist and refused every
request with 403 "Invalid Host header", even for the correct hostname.
It only passed locally because PHP's built-in server exposes headers
differently.

Collapse Host to the single value the client sent before the middleware
runs. The rebinding check still sees the client's real Host, so foreign
hosts and direct-by-IP access stay refused.

Verified on the beta host by running the patched entrypoint against
beta's real config and vendor/: the correct host gets the Protected
Resource Metadata, and a foreign host still gets 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 18:46:52 -04:00
jaredandClaude Opus 5.5 5631731a28 Add OAuth-protected MCP endpoint scaffolding (#111, phase 2)
Lint / PHP (phpcs PSR-12) (push) Successful in 51s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m17s
Lint / Deploy (push) Successful in 7s
First step of the remote MCP server: mcp/server.php serves /mcp over
Streamable HTTP via the official MCP PHP SDK (mcp/sdk, pinned to exactly
0.8.1 since it breaks BC in nearly every minor release), with Authelia as
the OAuth authorization server. No tools yet: this phase only stands up
authentication, RFC 9728 Protected Resource Metadata, and routing.

- Composer is introduced for the MCP endpoint ONLY: nothing else loads
  vendor/autoload.php, so a failed composer install at deploy time can
  only take /mcp down. vendor/ is gitignored and excluded from phpcs;
  composer.lock is resolved for PHP 8.2 so it installs on 8.2 and 8.4.
- Tokens are validated against Authelia's JWKS (cached) for signature,
  issuer, audience == MCP_RESOURCE_URL (a beta token is rejected by prod
  and vice versa), and expiry. scopeClaim is 'scp' because Authelia puts
  scopes in an array claim of that name, not the standard 'scope'.
- The request URI's scheme/host are pinned to MCP_RESOURCE_URL before the
  SDK sees it: TLS ends at the proxy, so PHP sees http and a
  client-controlled Host, and the SDK builds the 401 challenge's
  resource_metadata URL from that. preserveHost keeps the real Host
  header for the DNS-rebinding check, which only allows the canonical
  hostname (so direct-by-IP access is refused too).
- Identity will come only from the token; this entrypoint never reads
  Remote-* headers or $_SESSION, since /mcp is exempt from forward-auth
  at the proxy and those headers are client-controlled there.

Verified locally with PHP's built-in server: unauthenticated POST gets
401 + WWW-Authenticate with the https resource_metadata URL and scopes;
metadata served at both /.well-known/oauth-protected-resource/mcp and the
root form; malformed token -> 401 invalid_token; foreign Host and
direct-IP Host -> 403; a forged Remote-User header without a token is
still 401.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 18:40:11 -04:00
jaredandClaude Sonnet 5 d0a763079e Merge development into main: API key visibility, notification retry, watch audit logging (#70, #78, #93)
Lint / PHP (phpcs PSR-12) (push) Successful in 35s
Lint / JS (eslint) (push) Successful in 26s
Lint / PHP requirements (version + extensions) (push) Successful in 43s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m45s
Lint / Deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-12 01:20:57 -04:00
jaredandClaude Sonnet 5 35192aaadc Retry failed Matrix webhook notifications with backoff (#78)
Lint / PHP (phpcs PSR-12) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 21s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m8s
Lint / Deploy (push) Successful in 5s
NotificationHelper::fire() logged a failed webhook post via error_log()
only, with no retry and no persistent record — once a notification
failed, it was gone with no trace beyond the log line, even though the
underlying DB write (audit_log entry, status change, etc.) it was
reporting on had already committed.

Extracted the curl POST into attemptDelivery(), shared between fire()
(unchanged best-effort caller-facing behavior) and the new
cron/retry_failed_notifications.php. On failure, fire() now also queues
the payload to notification_retry_queue (migration 007) via
Database::getConnection() — most fire() call sites don't have a $conn
handy, and threading one through every caller would be a much larger,
more invasive change than reusing the existing connection singleton.

The cron script processes due rows with exponential backoff (2, 4, 8...
capped at 60 minutes) up to each row's max_attempts (default 6), then
leaves an exhausted row in place — not deleted — so it stays visible
for manual investigation instead of disappearing a second time.

Verified against real MariaDB and a local HTTP server standing in for
the Matrix webhook, toggled between failing and succeeding: confirmed
a real failure via fire() is correctly queued; the retry script
reschedules a still-failing row with the expected backoff delay;
flipping the fake webhook to succeed lets the same row's next retry
delete it; a row that exhausts all attempts is left in place and
correctly excluded from the next run's due-row query; and a success
via fire() queues nothing (no regression on the common case).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-12 01:18:13 -04:00
jaredandClaude Sonnet 5 6609320c83 Restrict API keys to public-visibility tickets by default (#70)
api/tickets_api.php (both the single-ticket read and the list/triage
path) bypassed ticket visibility entirely for any 'read'-scope key,
regardless of who it was issued to or what it was for — any key got
blanket read access to Confidential and Internal ticket titles,
descriptions, and comments, with no way to scope a key more narrowly.

Added see_all_visibility to api_keys (migration 006), defaulting to
false for both new and existing keys — the prior blanket-access
behavior is what's being restricted here, so unlike scope's own
un-migrated-database fallback (which defaults toward preserving old
behavior), a missing/null value here defaults to the new, restrictive
one. An admin can opt a specific key in via a new checkbox in the API
Key Management UI when it genuinely needs the full queue.

tickets_api.php now builds a synthetic "no special access" user and
runs it through TicketModel's existing per-user visibility plumbing
(getVisibilityFilter/canUserAccessTicket) instead of a separate SQL
path, so this stays in lockstep with however visibility rules evolve
for real users. That synthetic user_id is -1, not 0: testing surfaced
that canUserAccessTicket()'s confidential-ticket check does a PHP-level
(int) cast, and (int)null === 0, so an unassigned confidential ticket's
NULL assigned_to would otherwise false-positive-match a user_id of 0.

Verified against real MariaDB with public/confidential/internal test
tickets: a public-only-scoped key's list only returns the public
ticket, and canUserAccessTicket() correctly returns false for both the
confidential ticket (unassigned, then reassigned to a real user — both
cases) and the internal one; a see_all_visibility key sees all three,
unchanged from the prior behavior. Also verified createKey()/
validateKey()'s default-false and explicit-true paths round-trip
correctly through the real DB.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-12 01:17:53 -04:00
jaredandClaude Sonnet 5 e91f4547b6 Log watch/unwatch actions to the audit trail (#93)
api/watch_ticket.php performed the ticket_watchers INSERT IGNORE/DELETE
directly with no AuditLogModel call, unlike every other ticket-adjacent
mutation (comments, attachments, dependencies, status/field changes),
so watching/unwatching never showed up in a ticket's timeline.

Added AuditLogModel::log() calls to both the watch and unwatch paths,
gated on the DB statement's affected_rows so a no-op (already watching,
already not watching) doesn't produce a duplicate timeline entry. Added
'watch'/'unwatch' to AuditLogModel's VALID_ACTION_TYPES, and timeline
rendering in views/TicketView.php ("started watching this ticket" /
"stopped watching this ticket").

Verified against real MariaDB: watch -> unwatch -> watch again produces
exactly 2 timeline entries (not 4) since the two no-op repeats correctly
produced zero rows changed and were not logged; confirmed formatAction()/
getEventIcon() render both action types correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-12 01:17:13 -04:00
jaredandClaude Sonnet 5 74544ac5b4 Merge development into main: LDAP avatar lookups now use LDAPS (#95)
Lint / PHP (phpcs PSR-12) (push) Successful in 30s
Lint / JS (eslint) (push) Successful in 16s
Lint / PHP requirements (version + extensions) (push) Successful in 39s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m21s
Lint / Deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-12 00:59:21 -04:00
jaredandClaude Sonnet 5 863f84f37e Switch LDAP avatar lookups to LDAPS (#95)
Lint / PHP (phpcs PSR-12) (push) Successful in 18s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 28s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m56s
Lint / Deploy (push) Successful in 3s
api/user_avatar.php connected via ldap://$ldapHost:$ldapPort — never
ldaps://, and there was no ldap_start_tls() call anywhere in the
codebase. LDAP_BIND_PW was sent over the wire unencrypted on every
avatar fetch.

Switched to ldaps://, and changed LDAP_HOST/LDAP_PORT's defaults to
ldap.lotusguild.org:6360 (lldap's LDAPS listener) instead of the bare
IP on port 3890 (plaintext). PHP's ldap extension verifies the server
cert's hostname by default, so a bare IP won't validate against the
LDAPS cert (issued for *.lotusguild.org) — LDAP_HOST has to be a
hostname the cert covers. This is deliberately not configurable back to
plaintext ldap://.

Infra change (pve-infra, separate repo/commit): added a Pi-hole
split-horizon override so ldap.lotusguild.org resolves internally to
the real LDAP server's LAN IP — its existing public DNS record points
elsewhere (an unrelated host), and there was no internal-only DNS entry
for it before this.

Verified against the real lldap server (pct 147, LDAPS on 6360, a live
Let's Encrypt *.lotusguild.org cert): confirmed the Pi-hole override
resolves correctly from hosts using it as their resolver, then ran the
exact ldap_connect/ldap_bind sequence via `php -r` directly on the
production tinker_tickets host (10.10.10.45) with a deliberately wrong
bind password — got "Invalid credentials" (a real LDAP protocol
response), not a transport/TLS error, proving the full connect + TLS
handshake + hostname verification + bind path works end-to-end in the
actual deployment environment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-12 00:56:00 -04:00
jaredandClaude Sonnet 5 b73a4c792c Merge development into main: dashboard status chart excludes Closed (#110)
Lint / PHP (phpcs PSR-12) (push) Successful in 54s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 24s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m2s
Lint / Deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 22:26:37 -04:00
jaredandClaude Sonnet 5 78ee5fdf48 Exclude Closed tickets from the dashboard status breakdown chart (#110)
Lint / PHP (phpcs PSR-12) (push) Successful in 31s
Lint / JS (eslint) (push) Successful in 13s
Lint / PHP requirements (version + extensions) (push) Successful in 31s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m4s
Lint / Deploy (push) Successful in 6s
StatsModel::getAllStats()'s by_status breakdown grouped every status
including Closed, unlike by_priority and by_category which already
filter status != 'Closed'. Closed tickets accumulate indefinitely, so
over time the status donut chart's Closed slice comes to dominate it,
squeezing Open/Pending/In Progress down to barely-visible slivers —
exactly the breakdown the chart exists to show at a glance.

Filtered by_status the same way the other two breakdowns already are.
The separate open_tickets/closed_tickets KPI counts are unaffected (a
different query); Closed just no longer appears as a chart segment.

Note: this issue was labeled 'invalid' with no explanation in the body
or comments — checked and it's Gitea's generic stock label (description
"Something is wrong"), not a documented decision that the request itself
was wrong. The described problem is real and reproducible in the code,
so implementing it as filed.

Verified against real MariaDB: seeded 3 open-ish tickets (Open, Pending,
In Progress) and 3 Closed. by_status now returns only the 3 active
statuses; open_tickets/closed_tickets KPI counts are unchanged at 3/3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 22:23:33 -04:00
jaredandClaude Sonnet 5 786674abf3 Merge development into main: user-activity fix + attachment thumbnails (#49, #98)
Lint / PHP (phpcs PSR-12) (push) Successful in 18s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m48s
Lint / Deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 22:19:57 -04:00
jaredandClaude Sonnet 5 dcf9b0cfa1 Generate real resized thumbnails for image attachments (#98)
Lint / PHP (phpcs PSR-12) (push) Successful in 17s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m8s
Lint / Deploy (push) Successful in 2s
The attachment grid's <img> thumbnail pointed at the same
download_attachment.php URL as the full-size original, so previewing a
multi-MB photo attachment cost a full multi-MB download just to render a
small grid preview. loading="lazy" only deferred off-screen images; it
never reduced per-image transfer size.

Generate a resized JPEG thumbnail (longest side capped at 300px) via GD
at upload time, from the same metadata-stripped image stripImageMetadata()
already produces, reusing its decompression-bomb guard (~40MP decode cap).
Store the thumbnail's filename in a new nullable ticket_attachments.
thumbnail_filename column (migration 005); NULL means no thumbnail exists
(non-image, GD unavailable, or an attachment predating this change) and
callers fall back to the full-size original.

download_attachment.php serves the thumbnail when requested via
?thumb=1 and one exists, falling back to the original otherwise. The
attachments grid now requests thumb=1 for its <img> preview; the
lightbox link is unchanged and still opens the full-size original.
delete_attachment.php removes the thumbnail file alongside the original,
and cleanup_orphan_uploads.php's orphan lookup now also matches
thumbnail_filename so generated thumbnails aren't swept up as orphans.

Verified against real MariaDB + GD: a 1600x1200 test JPEG produced a
300x225 thumbnail at ~1.8KB vs. the 52KB original (~29x smaller);
confirmed the serving logic picks the thumbnail for image attachments
with one, falls back to the original for a non-image attachment even
when thumb=1 is requested, and that the updated orphan-cleanup lookup
matches both the original and thumbnail filename (and correctly finds
neither for an unrelated filename).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 22:17:35 -04:00
jaredandClaude Sonnet 5 80169de16d Fix User Activity report's Tickets Assigned column to use assignment date (#49)
The "Tickets Assigned" column filtered by tickets.created_at, so a ticket
created outside the selected date range but assigned to a user within it
never counted, while one created in-range but assigned/reassigned later
counted as if the assignment happened in-range — filtered by the wrong
date field for what the column claims to measure.

tickets has no assigned_at column, so derive the count from audit_log's
'assign' events (already logged by both the single-ticket and bulk-assign
paths) instead, filtered by the event's own created_at. COUNT(DISTINCT
entity_id) so a ticket reassigned more than once to the same user within
the range still counts once.

Verified against real MariaDB: seeded one ticket created outside the test
range but assigned inside it, and one created inside the range but
assigned outside it. The old query counted the wrong one; the new query
correctly flips to count the one actually assigned within the range.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 22:17:25 -04:00
47 changed files with 4590 additions and 662 deletions
+7 -3
View File
@@ -74,10 +74,14 @@ TRUSTED_PROXIES=
; Timezone (default: America/New_York)
TIMEZONE=America/New_York
; LDAP / lldap (for user avatar lookups)
; LDAP / lldap (for user avatar lookups). Connects over LDAPS — 6360 is
; lldap's default LDAPS port, NOT its plaintext port (3890), since
; LDAP_BIND_PW below would otherwise go over the wire unencrypted.
; LDAP_HOST must be a hostname matching the LDAPS cert (TLS hostname
; verification is not skipped), not a bare IP.
LDAP_ENABLED=true
LDAP_HOST=10.10.10.39
LDAP_PORT=3890
LDAP_HOST=ldap.lotusguild.org
LDAP_PORT=6360
LDAP_BIND_DN="uid=tinker-tickets,ou=people,dc=example,dc=com"
LDAP_BIND_PW=
LDAP_BASE_DN="dc=example,dc=com"
+3 -1
View File
@@ -6,4 +6,6 @@ settings.local.json
# Upload files (keep folder structure, ignore actual uploads)
uploads/*
!uploads/.gitkeep
!uploads/.htaccess
!uploads/.htaccess
# Composer dependencies (used only by the MCP endpoint; installed at deploy time)
vendor/
+1
View File
@@ -6,6 +6,7 @@
<exclude-pattern>*/uploads/*</exclude-pattern>
<exclude-pattern>*/migrations/*</exclude-pattern>
<exclude-pattern>*/.gitea/*</exclude-pattern>
<exclude-pattern>*/vendor/*</exclude-pattern>
<arg name="extensions" value="php"/>
<arg name="colors"/>
+49 -1
View File
@@ -97,12 +97,15 @@ The following features are intentionally **not planned** for this system:
- **Admin UI**: Generate and manage API keys at `/admin/api-keys` (paginated)
- **Bearer Token Auth**: Use API keys with `Authorization: Bearer YOUR_KEY` header
- **Key Scopes**: `read` (GET only) or `read_write` (create/comment/close). A `read` key cannot mutate anything, including creating tickets. Existing keys default to `read_write`.
- **Visibility Scope**: keys default to **public-visibility tickets only** — Confidential and Internal tickets are excluded from `/api/tickets_api.php`, same as they'd be for a regular user with no special access. Check **See all visibility** when generating a key only if that specific integration genuinely needs the full queue; this is a deliberate opt-in, not something a key gets by having `read_write` scope or any other setting.
- **Expiration**: Optional expiration dates for keys
- **Revocation**: Revoke compromised keys instantly
### Bearer API (automation / triage)
All Bearer-authenticated, rate-limited, and (like `create_ticket_api.php`) exempt from Authelia at the reverse proxy — the API key is the only credential. Comments/closes made via the API are attributed to the **key's name** (linked to the key's owner).
`/api/tickets_api.php` filters by ticket visibility according to the key's **Visibility Scope** setting above (public-only by default); every other Bearer endpoint below operates on a single ticket_id supplied by the caller and does not filter a list, so this setting doesn't apply to them.
| Endpoint | Method | Scope | Purpose |
|----------|--------|-------|---------|
| `/create_ticket_api.php` | POST | read_write | Create a ticket (hwmonDaemon, external tools) |
@@ -110,6 +113,33 @@ All Bearer-authenticated, rate-limited, and (like `create_ticket_api.php`) exemp
| `/api/ticket_comment_api.php` | POST | read_write | Add a comment: `{ticket_id, comment_text, markdown_enabled?}` |
| `/api/ticket_status_api.php` | POST | read_write | Change/close status (workflow-validated): `{ticket_id, status, comment?}` — `comment` is required for transitions that require one (e.g. → Closed); it is posted as the close reason in the same call |
### MCP server (Claude Code and other MCP clients)
`/mcp` is a remote [MCP](https://modelcontextprotocol.io) server (Streamable HTTP) protected by **OAuth via Authelia** — no API keys. You sign in once in the browser and every action runs **as you**: the same visibility, permissions, Workflow Designer rules, audit log and notifications as the web UI.
**Connect Claude Code** (one-time):
```bash
claude mcp add --transport http --scope user \
--client-id tinker-tickets-mcp --callback-port 47823 \
tinker https://t.lotusguild.org/mcp
claude mcp login tinker # add --no-browser on a headless machine, then paste the redirect URL back
```
`--client-id` / `--callback-port 47823` are required: Authelia doesn't support dynamic client registration, so Claude Code uses the pre-registered public client `tinker-tickets-mcp`, whose only allowed redirect is `http://localhost:47823/callback`. Access requires the same `admin`/`employee` group as the web UI. Tokens refresh automatically (refresh token lasts 30 days); `claude mcp logout tinker` signs out.
| Tool | Scope | What it does |
|------|-------|--------------|
| `search_tickets` | `tickets:read` | Search/list tickets you can see (text, status, priority, category, assignee `me`/`unassigned`/username), paginated |
| `get_ticket` | `tickets:read` | One ticket's details + comments |
| `create_ticket` | `tickets:write` | Create a ticket (title, description, priority, category, type, visibility, assignee) |
| `add_comment` | `tickets:write` | Comment (markdown, @mentions, replies) |
| `update_status` | `tickets:write` | Workflow-validated status change; `comment` required when the transition requires one |
| `assign_ticket` | `tickets:write` | Assign / unassign (admin, creator, or current assignee only) |
How it fits together (see issue #111 for the full design):
- **Authelia** is the authorization server: client `tinker-tickets-mcp`, custom scopes `tickets:read`/`tickets:write`, RS256 JWT access tokens carrying `preferred_username`/`groups`. Needs Authelia **≠ 4.39.21/4.39.22** (RFC 8707 `resource` bug); 4.39.20 or ≥ 4.39.23 are fine.
- **`mcp/server.php`** is the resource server (official `mcp/sdk`, pinned). It validates each token's signature (JWKS), issuer, **audience = this server's URL** (`MCP_RESOURCE_URL`, derived from `APP_DOMAIN`, so a beta token can't be used on prod) and expiry, then maps the user with the same rules as the web login. It serves RFC 9728 metadata at `/.well-known/oauth-protected-resource/mcp`.
- **Composer is used only by the MCP endpoint** (`vendor/` is gitignored and installed by the deploy script with `composer install --no-dev`); nothing else in the app loads it.
- **Reverse proxy:** `/mcp` and `/.well-known/oauth-protected-resource` are exempt from Authelia forward-auth (the token is the credential) and have `Remote-*` headers blanked; `vendor/`, `mcp/` and `composer.*` are never served.
### User Management & Authentication
- **SSO Integration**: Authelia authentication with LLDAP backend
- **Role-Based Access**: Admin and standard user roles
@@ -352,7 +382,9 @@ tinker_tickets/
│ ├── config.php # Config + .env loading
│ └── requirements.php # PHP version/extension requirements (single source of
│ # truth for scripts/check_requirements.php + api/health.php)
├── composer.json / composer.lock # MCP endpoint dependencies only (mcp/sdk pinned); vendor/ installed at deploy
├── controllers/
│ ├── ApiTicketController.php # Partial ticket updates (status/fields/visibility), shared by update_ticket.php + MCP
│ ├── CommentController.php # Comment create/edit/delete + notifications
│ ├── DashboardController.php # Dashboard with stats + filters
│ └── TicketController.php # Ticket CRUD + timeline + visibility
@@ -396,11 +428,24 @@ tinker_tickets/
│ ├── 002_fix_collation_consistency.sql # Upgrade-only (already in baseline for fresh installs)
│ ├── 003_fk_on_delete_set_null.sql # Upgrade-only (already in baseline for fresh installs)
│ ├── 004_fix_ticket_watchers_type.sql # Upgrade-only (already in baseline for fresh installs)
│ ├── 005_attachment_thumbnails.sql # ticket_attachments.thumbnail_filename
│ ├── 006_api_key_visibility_scope.sql # api_keys.see_all_visibility (public-only by default)
│ ├── 007_notification_retry_queue.sql # Queue for failed Matrix webhook retries
│ └── migrate.php # CLI migration runner (tracks applied migrations)
├── mcp/
│ ├── server.php # MCP endpoint (/mcp): OAuth resource server; only file that loads vendor/
│ └── src/
│ ├── Auth/ # IdentityMiddleware (token -> user), ToolScopeMiddleware, McpIdentity
│ ├── Tools/ # TicketReadTools, TicketWriteTools
│ └── ToolCatalog.php # Tool registry + which tools need tickets:write
├── scripts/
│ ├── check_requirements.php # Verify PHP extensions/config prerequisites
│ └── cleanup_orphan_uploads.php # Delete orphaned upload files past grace period (cron)
├── uploads/ # File attachment storage
├── services/
│ ├── AssignmentService.php # Assign/unassign (shared by assign_ticket.php + MCP)
│ ├── CommentService.php # Add comment + mentions/notifications (shared by add_comment.php + MCP)
│ └── TicketCreationService.php # Create ticket (shared by TicketController + MCP)
├── uploads/ # File attachment storage (served only via PHP; nginx: internal)
│ └── avatars/ # lldap avatar disk cache
├── views/
│ ├── admin/
@@ -522,6 +567,9 @@ Add to crontab for recurring tickets and maintenance cleanup:
# Delete orphaned upload files with no attachment row, past a 24h grace period (daily).
# Add --dry-run to preview without deleting.
0 4 * * * php /path/to/tinkertickets/scripts/cleanup_orphan_uploads.php
# Retry failed Matrix webhook notifications (exponential backoff, every 5 minutes)
*/5 * * * * php /path/to/tinkertickets/cron/retry_failed_notifications.php
```
### 3. File Uploads
+11 -144
View File
@@ -80,155 +80,22 @@ try {
exit;
}
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
if (!ctype_digit($ticketId) || (int)$ticketId <= 0) {
http_response_code(400);
// Validation, access check, mentions, audit log and notifications live in
// CommentService so the MCP add_comment tool runs the same code path.
require_once dirname(__DIR__) . '/services/CommentService.php';
$result = CommentService::addComment($conn, $currentUser, $data);
if (!empty($result['http_status'])) {
http_response_code($result['http_status']);
unset($result['http_status']);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
echo json_encode($result);
exit;
}
// Reject empty/whitespace-only comments
$commentTextRaw = isset($data['comment_text']) ? trim((string)$data['comment_text']) : '';
if ($commentTextRaw === '') {
http_response_code(400);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Comment text cannot be empty']);
exit;
}
// Persist the trimmed text (not the raw client value) — matches update_comment.php
// and keeps stored comment_text free of leading whitespace that could shift a
// markdown-enabled comment's first line out of column 0 on reload.
$data['comment_text'] = $commentTextRaw;
// Never trust a client-supplied display name — always attribute the comment to
// the authenticated session user.
$data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
// Verify user can access the ticket before allowing a comment
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
http_response_code(404);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(403);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Access denied']);
exit;
}
// Initialize models
$commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn);
// If replying, the parent comment must belong to this same (accessible) ticket.
if (isset($data['parent_comment_id']) && $data['parent_comment_id'] !== null && $data['parent_comment_id'] !== '') {
$parentComment = $commentModel->getCommentById((int)$data['parent_comment_id']);
if (!$parentComment || (string)$parentComment['ticket_id'] !== (string)$ticketId) {
http_response_code(400);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid parent comment']);
exit;
}
}
// Extract @mentions from comment text
$mentions = $commentModel->extractMentions($data['comment_text'] ?? '');
$mentionedUsers = [];
if (!empty($mentions)) {
$mentionedUsers = $commentModel->getMentionedUsers($mentions);
}
// Add comment with user tracking
$result = $commentModel->addComment($ticketId, $data, $userId);
// Log comment creation to audit log
if ($result['success'] && isset($result['comment_id'])) {
$auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
// Log mentions to audit log
foreach ($mentionedUsers as $mentionedUser) {
$auditLog->log(
$userId,
'mention',
'user',
(string)$mentionedUser['user_id'],
[
'ticket_id' => $ticketId,
'comment_id' => $result['comment_id'],
'mentioned_username' => $mentionedUser['username']
]
);
}
// Matrix notifications
$authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$commentText = $data['comment_text'] ?? '';
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
$ticketVisibility = $ticket['visibility'] ?? 'public';
// @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);
}
}
// General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
NotificationHelper::sendCommentNotification(
$ticketId,
$ticketTitle,
$commentText,
$authorDisplay,
$ticketVisibility !== 'public',
$ticketVisibility
);
}
// Notify watchers of the new comment
NotificationHelper::notifyWatchers(
$conn,
$ticketId,
$ticketTitle,
'comment_added',
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
(int)$userId,
$ticketVisibility
);
// Add mentioned users to result for frontend
$result['mentions'] = array_map(function ($u) {
return $u['username'];
}, $mentionedUsers);
}
// Add user info to result for frontend avatar rendering
if ($result['success']) {
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
$result['user_id'] = $userId;
if (isset($newCsrfToken)) {
$result['csrf_token'] = $newCsrfToken;
}
if ($result['success'] && isset($newCsrfToken)) {
$result['csrf_token'] = $newCsrfToken;
}
// Discard any unexpected output
+9 -79
View File
@@ -1,11 +1,7 @@
<?php
require_once __DIR__ . '/bootstrap.php';
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';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
require_once dirname(__DIR__) . '/services/AssignmentService.php';
// Get request data
$data = json_decode(file_get_contents('php://input'), true);
@@ -15,79 +11,13 @@ if (!is_array($data)) {
exit;
}
$ticketIdRaw = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
$assignedTo = $data['assigned_to'] ?? null;
// Validation, permission check, audit log, notification and stats-cache
// invalidation live in AssignmentService so the MCP assign_ticket tool runs
// the same code path.
$result = AssignmentService::assign($conn, $currentUser, $data);
if (!ctype_digit($ticketIdRaw) || (int)$ticketIdRaw <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
exit;
}
$ticketId = $ticketIdRaw;
$ticketModel = new TicketModel($conn);
$auditLogModel = new AuditLogModel($conn);
$userModel = new UserModel($conn);
// Verify ticket exists and user can access it
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
// Authorization: only admins or the ticket creator/assignee can reassign
if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Permission denied']);
exit;
}
if ($assignedTo === null || $assignedTo === '') {
// Unassign ticket
$success = $ticketModel->unassignTicket($ticketId, $userId);
if ($success) {
$auditLogModel->log($userId, 'unassign', 'ticket', $ticketId);
}
} else {
// Validate assigned_to is a valid user ID
$assignedTo = (int)$assignedTo;
$targetUser = $userModel->getUserById($assignedTo);
if (!$targetUser) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid user ID']);
exit;
}
// Assign ticket
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
if ($success) {
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) {
$changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null;
$assigneeMatrix = isset($targetUser['username'])
? SynapseHelper::resolveUsername($targetUser['username'])
: null;
NotificationHelper::sendAssignmentNotification(
$ticketId,
$ticket['title'] ?? "Ticket #{$ticketId}",
$assigneeName,
$assigneeMatrix,
$changedByDisplay,
$ticket['visibility'] ?? 'public'
);
}
}
}
if (!$success) {
http_response_code(500);
apiRespond(['success' => false, 'error' => 'Failed to update ticket assignment']);
} else {
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($conn))->invalidateCache();
apiRespond(['success' => true]);
if (!empty($result['http_status'])) {
http_response_code($result['http_status']);
unset($result['http_status']);
}
apiRespond($result);
+9
View File
@@ -94,6 +94,15 @@ try {
}
}
// Delete the generated preview thumbnail alongside the original, if one exists.
if (!empty($attachment['thumbnail_filename'])) {
$thumbPath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['thumbnail_filename'];
$realThumbPath = realpath($thumbPath);
if ($realThumbPath !== false && strncmp($realThumbPath, $uploadDir . DIRECTORY_SEPARATOR, strlen($uploadDir) + 1) === 0) {
@unlink($realThumbPath);
}
}
// Delete from database
if (!$attachmentModel->deleteAttachment($attachmentId)) {
ResponseHelper::serverError('Failed to delete attachment record');
+15 -3
View File
@@ -69,9 +69,21 @@ try {
$conn->close();
// Serve the resized preview thumbnail instead of the full-size original
// when requested and one was actually generated at upload time; falls
// through to the full original otherwise (older attachments predating
// thumbnail generation, non-images, or a GD failure at upload time).
$wantsThumb = isset($_GET['thumb']) && $_GET['thumb'] === '1';
$servedFilename = $attachment['filename'];
$servedMimeType = $attachment['mime_type'];
if ($wantsThumb && !empty($attachment['thumbnail_filename'])) {
$servedFilename = $attachment['thumbnail_filename'];
$servedMimeType = 'image/jpeg';
}
// Build file path
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $servedFilename;
// Security: Verify the resolved path is within the uploads directory (prevent path traversal)
$realUploadDir = realpath($uploadDir);
@@ -100,7 +112,7 @@ try {
$inlineTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain'];
// Set headers
$disposition = ($inline && in_array($attachment['mime_type'], $inlineTypes)) ? 'inline' : 'attachment';
$disposition = ($inline && in_array($servedMimeType, $inlineTypes)) ? 'inline' : 'attachment';
// Sanitize filename for Content-Disposition
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
@@ -138,7 +150,7 @@ try {
$rangeLength = $rangeEnd - $rangeStart + 1;
header('Accept-Ranges: bytes');
header('Content-Type: ' . $attachment['mime_type']);
header('Content-Type: ' . $servedMimeType);
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
header('Cache-Control: private, max-age=3600');
header('X-Content-Type-Options: nosniff');
+4 -2
View File
@@ -67,6 +67,7 @@ try {
$keyName = trim($input['key_name'] ?? '');
$expiresInDays = $input['expires_in_days'] ?? null;
$scope = $input['scope'] ?? 'read_write';
$seeAllVisibility = !empty($input['see_all_visibility']);
if (empty($keyName)) {
http_response_code(400);
@@ -100,7 +101,7 @@ try {
// Generate API key
$apiKeyModel = new ApiKeyModel($conn);
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays, $scope);
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays, $scope, $seeAllVisibility);
if (!$result['success']) {
throw new Exception($result['error'] ?? "Failed to generate API key");
@@ -113,7 +114,7 @@ try {
'create',
'api_key',
$result['key_id'],
['key_name' => $keyName, 'expires_in_days' => $expiresInDays, 'scope' => $scope]
['key_name' => $keyName, 'expires_in_days' => $expiresInDays, 'scope' => $scope, 'see_all_visibility' => $seeAllVisibility]
);
// Clear output buffer
@@ -127,6 +128,7 @@ try {
'key_prefix' => $result['key_prefix'],
'key_id' => $result['key_id'],
'scope' => $result['scope'],
'see_all_visibility' => $result['see_all_visibility'],
'expires_at' => $result['expires_at']
]);
} catch (Exception $e) {
+30 -5
View File
@@ -4,8 +4,10 @@
* tickets_api.php — Bearer-key read endpoint (list/triage + read-one).
*
* GET only. Requires 'read' scope (a 'read_write' key also satisfies it).
* Acts as a trusted automation/server credential: reads return the full queue
* (no per-user visibility filtering).
* By default, a key only sees public-visibility tickets — Confidential and
* Internal tickets are excluded, the same as they would be for a regular user
* with no special access. An admin can mark a specific key see_all_visibility
* (API Key Management) when it genuinely needs the full queue.
*
* GET ?ticket_id=NNN -> {success, ticket, comments}
* GET ?status=&priority=&host= -> {success, tickets, page, total, pages}
@@ -48,6 +50,20 @@ try {
// Reads only need the 'read' scope.
$apiKeyAuth->requireScope('read');
// Keys are public-ticket-only by default (#70) — a key must be explicitly
// marked see_all_visibility to bypass Confidential/Internal restrictions.
// Reuse TicketModel's existing per-user visibility plumbing with a synthetic
// "no special access" user rather than a separate SQL path, so this stays in
// lockstep with however visibility rules evolve for real users. user_id is
// -1, not 0: canUserAccessTicket() does a PHP-level (int) cast for the
// confidential-ticket check, and (int)null === 0, so an unassigned
// confidential ticket's assigned_to would otherwise false-positive-match a
// synthetic user_id of 0. No real user_id is ever <= 0, so -1 can't collide.
$keyContext = $apiKeyAuth->getKeyContext();
$visibilityUser = !empty($keyContext['see_all_visibility'])
? null
: ['user_id' => -1, 'is_admin' => false, 'groups' => ''];
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed. Use GET.']);
@@ -67,6 +83,15 @@ if (isset($_GET['ticket_id']) && trim((string)$_GET['ticket_id']) !== '') {
exit;
}
// A public-only key gets a plain 404 for a non-public ticket — same as a
// regular user hitting a ticket they can't see — rather than a 403 that
// would confirm the ticket exists.
if ($visibilityUser !== null && !$ticketModel->canUserAccessTicket($ticket, $visibilityUser)) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
// Flat list of comments (newest first) — same fetch the ticket view uses.
$commentModel = new CommentModel($conn);
$comments = $commentModel->getCommentsByTicketId($ticketId, false);
@@ -114,8 +139,8 @@ if (isset($_GET['host']) && trim((string)$_GET['host']) !== '') {
$search = trim((string)$_GET['host']);
}
// user = null => getAllTickets skips visibility filtering and returns the full
// queue (this is a trusted server credential, not an end user).
// $visibilityUser is null (skip filtering, full queue) only for a key marked
// see_all_visibility; otherwise it restricts to public tickets (see above).
$result = $ticketModel->getAllTickets(
$page,
$limit,
@@ -126,7 +151,7 @@ $result = $ticketModel->getAllTickets(
null,
$search,
$filters,
null
$visibilityUser
);
echo json_encode([
+1 -256
View File
@@ -62,262 +62,7 @@ try {
$userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false;
// Updated controller class that handles partial updates
class ApiTicketController
{
private $conn;
private $ticketModel;
private $commentModel;
private $auditLog;
private $workflowModel;
private $userId;
private $isAdmin;
private $currentUser;
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = [])
{
$this->conn = $conn;
$this->ticketModel = new TicketModel($conn);
$this->commentModel = new CommentModel($conn);
$this->auditLog = new AuditLogModel($conn);
$this->workflowModel = new WorkflowModel($conn);
$this->userId = $userId;
$this->isAdmin = $isAdmin;
$this->currentUser = $currentUser;
}
public function update($id, $data)
{
// First, get the current ticket data to fill in missing fields
$currentTicket = $this->ticketModel->getTicketById($id);
if (!$currentTicket) {
return [
'success' => false,
'error' => 'Ticket not found'
];
}
// Visibility check: return 404 for tickets the user cannot access
if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) {
return [
'success' => false,
'error' => 'Ticket not found',
'http_status' => 404
];
}
// Any authenticated team member can update tickets.
// Admin-only operations (delete, bulk actions) are enforced separately.
// Merge current data with updates, keeping existing values for missing fields
$updateData = [
'ticket_id' => $id,
'title' => $data['title'] ?? $currentTicket['title'],
'description' => $data['description'] ?? $currentTicket['description'],
'category' => $data['category'] ?? $currentTicket['category'],
'type' => $data['type'] ?? $currentTicket['type'],
'status' => $data['status'] ?? $currentTicket['status'],
'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority']
];
// Validate required fields
if (empty($updateData['title'])) {
return [
'success' => false,
'error' => 'Title cannot be empty',
'http_status' => 400
];
}
// Validate priority range
if ($updateData['priority'] < 1 || $updateData['priority'] > 5) {
return [
'success' => false,
'error' => 'Priority must be between 1 and 5',
'http_status' => 400
];
}
// Validate visibility BEFORE any DB write so a bad payload can't leave the
// ticket half-updated (core fields committed but request reported as failed).
$visibilityGroups = null;
if (isset($data['visibility'])) {
$visibilityGroups = $data['visibility_groups'] ?? null;
// Convert array to comma-separated string if needed
if (is_array($visibilityGroups)) {
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
}
// Authorization: only an admin or the ticket's creator may change
// visibility. Enforce only when the requested visibility actually
// differs so ordinary edits that re-send the same value aren't blocked.
$currentVisibility = $currentTicket['visibility'] ?? 'public';
$currentGroups = $currentTicket['visibility_groups'] ?? null;
$groupsProvided = array_key_exists('visibility_groups', $data);
$visibilityChanged = ($data['visibility'] !== $currentVisibility)
|| ($groupsProvided && (string)$visibilityGroups !== (string)$currentGroups);
if ($visibilityChanged) {
$isCreator = $this->userId !== null
&& (int)($currentTicket['created_by'] ?? 0) === (int)$this->userId;
if (!$this->isAdmin && !$isCreator) {
return [
'success' => false,
'error' => 'You do not have permission to change ticket visibility',
'http_status' => 403
];
}
}
// Internal visibility requires at least one group
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
return [
'success' => false,
'error' => 'Internal visibility requires at least one group to be specified',
'http_status' => 400
];
}
}
// Validate status transition using workflow model
if ($currentTicket['status'] !== $updateData['status']) {
$allowed = $this->workflowModel->isTransitionAllowed(
$currentTicket['status'],
$updateData['status'],
$this->isAdmin
);
if (!$allowed) {
return [
'success' => false,
'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status']
];
}
// Enforce requires_comment transitions server-side.
if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) {
$statusChangeComment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
if ($statusChangeComment === '') {
return [
'success' => false,
'error' => 'A comment is required for this status change',
'requires_comment' => true,
'http_status' => 400
];
}
}
}
// A comment accompanying a status change (required or optional) is
// persisted in the SAME transaction as the status update below, so
// a failure partway through can't leave an orphaned "reason"
// comment attached with no matching status change — the two
// previously ran as separate, non-transactional HTTP calls from
// the client (add_comment.php then update_ticket.php).
$statusChangeComment = $statusChangeComment ?? trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
$result = null;
$this->conn->begin_transaction();
try {
if ($statusChangeComment !== '' && $currentTicket['status'] !== $updateData['status']) {
$commentResult = $this->commentModel->addComment($id, [
'user_name' => $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? 'User',
'comment_text' => $statusChangeComment,
'markdown_enabled' => !empty($data['markdown_enabled']),
], $this->userId);
if (empty($commentResult['success'])) {
throw new Exception($commentResult['error'] ?? 'Failed to add comment');
}
}
// Update ticket with user tracking and optional optimistic locking
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
if (!$result['success']) {
throw new Exception($result['error'] ?? 'Failed to update ticket in database');
}
// Handle visibility update if provided (already validated above)
if (isset($data['visibility'])) {
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
if (!$visResult) {
throw new Exception('Failed to update ticket visibility');
}
}
$this->conn->commit();
} catch (Exception $e) {
$this->conn->rollback();
$response = ['success' => false, 'error' => $e->getMessage()];
if (is_array($result) && !empty($result['conflict'])) {
$response['conflict'] = true;
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
}
return $response;
}
if (isset($data['visibility']) && $this->userId) {
$this->auditLog->log(
$this->userId,
'update',
'ticket',
(string)$id,
[
'field' => 'visibility',
'from' => $currentTicket['visibility'] ?? 'public',
'to' => $data['visibility'],
'groups' => $visibilityGroups
]
);
}
// Log ticket update to audit log — only the changed fields (delta)
if ($this->userId) {
$trackFields = ['title', 'priority', 'status', 'description', 'category', 'type'];
$delta = [];
foreach ($trackFields as $field) {
$oldVal = (string)($currentTicket[$field] ?? '');
$newVal = (string)($updateData[$field] ?? '');
if ($oldVal !== $newVal) {
$delta[$field] = ['from' => $oldVal, 'to' => $newVal];
}
}
if (!empty($delta)) {
$this->auditLog->logTicketUpdate($this->userId, $id, $delta);
}
}
// Notify on status change (global notify list + watchers)
if ($currentTicket['status'] !== $updateData['status']) {
$changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null;
NotificationHelper::sendStatusChangeNotification(
$id,
$currentTicket['status'],
$updateData['status'],
$updateData['title'],
$changedBy,
$currentTicket['visibility'] ?? 'public'
);
NotificationHelper::notifyWatchers(
$this->conn,
$id,
$updateData['title'],
'status_changed',
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
(int)$this->userId,
$currentTicket['visibility'] ?? 'public'
);
}
return [
'success' => true,
'status' => $updateData['status'],
'priority' => $updateData['priority'],
'updated_at' => date('Y-m-d H:i:s'),
'message' => 'Ticket updated successfully',
'csrf_token' => $GLOBALS['newCsrfToken'] ?? null
];
}
}
require_once dirname(__DIR__) . '/controllers/ApiTicketController.php';
// Use centralized database connection
$conn = Database::getConnection();
+85 -4
View File
@@ -96,6 +96,72 @@ function stripImageMetadata(string $path, string $mimeType): void
}
}
/**
* Generate a resized preview thumbnail for an uploaded image, saved as a JPEG
* alongside the original regardless of source format (a thumbnail is a small
* lossy preview, not an archival copy). Longest side capped at
* THUMBNAIL_MAX_DIMENSION; images already at or below that size are still
* re-encoded (cheap) rather than skipped, so the thumbnail is guaranteed to
* be a JPEG the grid can always request the same way.
*
* Best-effort like stripImageMetadata(): returns null on any failure
* (corrupt image, unsupported format, GD unavailable) rather than blocking
* the upload, and the caller falls back to serving the full-size original.
*
* @return string|null Basename of the generated thumbnail file, or null
*/
function generateThumbnail(string $path, string $mimeType, string $destDir): ?string
{
if (!extension_loaded('gd')) {
return null;
}
// Same decompression-bomb guard as stripImageMetadata().
$dims = @getimagesize($path);
if ($dims === false) {
return null;
}
[$width, $height] = $dims;
if ($width * $height > 40_000_000) { // ~40 MP cap
return null;
}
$loaders = [
'image/jpeg' => 'imagecreatefromjpeg',
'image/png' => 'imagecreatefrompng',
'image/gif' => 'imagecreatefromgif',
'image/webp' => 'imagecreatefromwebp',
];
$loader = $loaders[$mimeType] ?? null;
if ($loader === null || !function_exists($loader)) {
return null;
}
$source = @$loader($path);
if ($source === false) {
return null;
}
$maxDimension = 300;
$scale = min(1.0, $maxDimension / max($width, $height));
$thumbWidth = max(1, (int)round($width * $scale));
$thumbHeight = max(1, (int)round($height * $scale));
$thumb = imagecreatetruecolor($thumbWidth, $thumbHeight);
// Flatten transparency onto white — thumbnails are always opaque JPEGs.
$white = imagecolorallocate($thumb, 255, 255, 255);
imagefill($thumb, 0, 0, $white);
imagecopyresampled($thumb, $source, 0, 0, 0, 0, $thumbWidth, $thumbHeight, $width, $height);
imagedestroy($source);
$thumbFilename = pathinfo($path, PATHINFO_FILENAME) . '_thumb.jpg';
$thumbPath = rtrim($destDir, '/') . '/' . $thumbFilename;
$saved = imagejpeg($thumb, $thumbPath, 80);
imagedestroy($thumb);
return $saved ? $thumbFilename : null;
}
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
ResponseHelper::unauthorized();
@@ -133,6 +199,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
foreach ($attachments as &$att) {
$att['file_size_formatted'] = AttachmentModel::formatFileSize($att['file_size']);
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
$att['has_thumbnail'] = !empty($att['thumbnail_filename']);
unset($att['thumbnail_filename']); // internal storage detail, not needed by the client
}
ResponseHelper::success([
@@ -278,9 +346,14 @@ 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
// Strip EXIF/GPS metadata from image uploads before it's ever served back,
// then generate a resized preview thumbnail from the (now metadata-stripped)
// original so the grid never has to transfer the full-size file just to
// render a small preview.
$thumbnailFilename = null;
if (str_starts_with($mimeType, 'image/')) {
stripImageMetadata($targetPath, $mimeType);
$thumbnailFilename = generateThumbnail($targetPath, $mimeType, $ticketDir);
}
// Sanitize original filename
@@ -298,12 +371,16 @@ try {
$originalFilename,
$file['size'],
$mimeType,
$_SESSION['user']['user_id']
$_SESSION['user']['user_id'],
$thumbnailFilename
);
if (!$attachmentId) {
// Clean up file if database insert fails
// Clean up file (and any thumbnail) if database insert fails
unlink($targetPath);
if ($thumbnailFilename !== null) {
@unlink($ticketDir . '/' . $thumbnailFilename);
}
ResponseHelper::serverError('Failed to save attachment record');
}
@@ -330,13 +407,17 @@ try {
'file_size_formatted' => AttachmentModel::formatFileSize($file['size']),
'mime_type' => $mimeType,
'icon' => AttachmentModel::getFileIcon($mimeType),
'has_thumbnail' => $thumbnailFilename !== null,
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
'uploaded_at' => date('Y-m-d H:i:s')
], 'File uploaded successfully');
} catch (Exception $e) {
// Clean up file on error
// Clean up file (and any thumbnail) on error
if (file_exists($targetPath)) {
unlink($targetPath);
}
if (isset($thumbnailFilename) && $thumbnailFilename !== null) {
@unlink($ticketDir . '/' . $thumbnailFilename);
}
ResponseHelper::serverError('Failed to process attachment');
}
+4 -1
View File
@@ -113,7 +113,10 @@ $avatarData = null;
$ldapQueryOk = false; // true only if the LDAP lookup completed without error
try {
$ldap = @ldap_connect("ldap://$ldapHost:$ldapPort");
// LDAPS, not plain ldap:// — LDAP_BIND_PW is sent during ldap_bind() below,
// and lldap's plaintext port (3890) would put it on the wire unencrypted.
// lldap's LDAPS listener defaults to port 6360 (see config.php).
$ldap = @ldap_connect("ldaps://$ldapHost:$ldapPort");
if (!$ldap) {
throw new RuntimeException("ldap_connect failed");
}
+10
View File
@@ -9,6 +9,7 @@
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
$data = json_decode(file_get_contents('php://input'), true) ?? [];
@@ -43,6 +44,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
);
$stmt->bind_param("si", $ticketId, $userId);
$stmt->execute();
$rowsChanged = $stmt->affected_rows;
$stmt->close();
} else {
$stmt = $conn->prepare(
@@ -50,9 +52,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
);
$stmt->bind_param("si", $ticketId, $userId);
$stmt->execute();
$rowsChanged = $stmt->affected_rows;
$stmt->close();
}
// Only log an actual state change — INSERT IGNORE/DELETE are no-ops when
// the user was already watching/not watching, and that shouldn't show up
// in the ticket's timeline as a new event.
if ($rowsChanged > 0) {
(new AuditLogModel($conn))->log($userId, $action, 'ticket', $ticketId);
}
// Return updated state
$countStmt = $conn->prepare(
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
+7 -2
View File
@@ -1238,10 +1238,15 @@ function renderAttachments(attachments, append, hasMore) {
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 = /^image\//i.test(att.mime_type || '');
const imgUrl = `/api/download_attachment.php?id=${att.attachment_id}&inline=1`;
const imgUrl = `/api/download_attachment.php?id=${att.attachment_id}&inline=1`;
// Grid preview requests the resized thumbnail (server falls back to the
// full-size original for attachments with none, e.g. uploaded before
// thumbnail generation existed); the lightbox link stays on the
// full-size original since that's what it displays when opened.
const thumbUrl = `${imgUrl}&thumb=1`;
const iconHtml = isImage
? `<a href="${imgUrl}" class="lt-lightbox-trigger" data-lightbox="ticket-attachments" title="${lt.escHtml(att.original_filename)}">
<img src="${imgUrl}" alt="${lt.escHtml(att.original_filename)}" class="attachment-thumb" loading="lazy">
<img src="${thumbUrl}" alt="${lt.escHtml(att.original_filename)}" class="attachment-thumb" loading="lazy">
</a>`
: `<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>`;
+33
View File
@@ -0,0 +1,33 @@
{
"name": "lotusguild/tinker-tickets",
"description": "Tinker Tickets. Composer is used ONLY by the MCP endpoint (mcp/); nothing else may require vendor/autoload.php.",
"type": "project",
"license": "proprietary",
"require": {
"php": ">=8.2",
"ext-openssl": "*",
"firebase/php-jwt": "^7.0",
"laminas/laminas-httphandlerrunner": "^2.12",
"mcp/sdk": "0.8.1",
"nyholm/psr7": "^1.8",
"nyholm/psr7-server": "^1.1",
"psr/simple-cache": "^3.0",
"symfony/cache": "^7.3",
"symfony/http-client": "^7.3"
},
"autoload": {
"psr-4": {
"TinkerTickets\\Mcp\\": "mcp/src/"
}
},
"config": {
"platform": {
"php": "8.2.0"
},
"sort-packages": true,
"optimize-autoloader": true,
"allow-plugins": {
"php-http/discovery": false
}
}
}
Generated
+2421
View File
File diff suppressed because it is too large Load Diff
+21 -3
View File
@@ -102,6 +102,14 @@ $GLOBALS['config'] = [
explode(',', $envVars['ALLOWED_HOSTS'] ?? 'localhost,127.0.0.1')
)),
// MCP endpoint (mcp/server.php). MCP_RESOURCE_URL is this server's
// canonical URL: access tokens must carry it as their audience, so a
// beta token can't be replayed against prod. MCP_OAUTH_ISSUER is the
// Authelia issuer that signs those tokens.
'MCP_RESOURCE_URL' => $envVars['MCP_RESOURCE_URL']
?? (!empty($envVars['APP_DOMAIN']) ? 'https://' . $envVars['APP_DOMAIN'] . '/mcp' : null),
'MCP_OAUTH_ISSUER' => $envVars['MCP_OAUTH_ISSUER'] ?? 'https://auth.lotusguild.org',
// Session settings
'SESSION_TIMEOUT' => 18000, // 5 hours in seconds
'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes
@@ -154,9 +162,19 @@ $GLOBALS['config'] = [
'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York',
'TIMEZONE_OFFSET' => null, // Will be calculated below
// LDAP / lldap settings (for user avatar lookups)
'LDAP_HOST' => $envVars['LDAP_HOST'] ?? '10.10.10.39',
'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 3890),
// LDAP / lldap settings (for user avatar lookups). Connects over LDAPS
// (see api/user_avatar.php) — lldap's default LDAPS port is 6360, not
// its plaintext port 3890. The bind password must never go over the
// wire unencrypted, so this is not configurable back to a plaintext
// ldap:// connection.
//
// LDAP_HOST must be a hostname matching the LDAPS cert's *.lotusguild.org
// CN/SAN, not a bare IP — PHP's ldap extension verifies the cert's
// hostname by default and a mismatch fails the connection. Pi-hole has a
// split-horizon override so ldap.lotusguild.org resolves internally to
// the real LDAP server IP (its public DNS record points elsewhere).
'LDAP_HOST' => $envVars['LDAP_HOST'] ?? 'ldap.lotusguild.org',
'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 6360),
'LDAP_BIND_DN' => $envVars['LDAP_BIND_DN'] ?? 'uid=tinker-tickets,ou=people,dc=example,dc=com',
'LDAP_BIND_PW' => $envVars['LDAP_BIND_PW'] ?? '',
'LDAP_BASE_DN' => $envVars['LDAP_BASE_DN'] ?? 'dc=example,dc=com',
+275
View File
@@ -0,0 +1,275 @@
<?php
/**
* Partial ticket updates for the web UI (api/update_ticket.php) and the MCP
* update_status tool: workflow-validated status transitions (including
* requires_comment, with the comment persisted in the same transaction),
* priority/field edits, visibility changes (admin or creator only), audit
* log delta, and status-change notifications. Returns result arrays; callers
* own HTTP concerns (sessions, CSRF, response codes) and must invalidate the
* stats cache on success.
*
* Moved verbatim out of api/update_ticket.php so the MCP endpoint runs the
* exact same code path as the web UI (tinker_tickets#111).
*/
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
class ApiTicketController
{
private $conn;
private $ticketModel;
private $commentModel;
private $auditLog;
private $workflowModel;
private $userId;
private $isAdmin;
private $currentUser;
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = [])
{
$this->conn = $conn;
$this->ticketModel = new TicketModel($conn);
$this->commentModel = new CommentModel($conn);
$this->auditLog = new AuditLogModel($conn);
$this->workflowModel = new WorkflowModel($conn);
$this->userId = $userId;
$this->isAdmin = $isAdmin;
$this->currentUser = $currentUser;
}
public function update($id, $data)
{
// First, get the current ticket data to fill in missing fields
$currentTicket = $this->ticketModel->getTicketById($id);
if (!$currentTicket) {
return [
'success' => false,
'error' => 'Ticket not found'
];
}
// Visibility check: return 404 for tickets the user cannot access
if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) {
return [
'success' => false,
'error' => 'Ticket not found',
'http_status' => 404
];
}
// Any authenticated team member can update tickets.
// Admin-only operations (delete, bulk actions) are enforced separately.
// Merge current data with updates, keeping existing values for missing fields
$updateData = [
'ticket_id' => $id,
'title' => $data['title'] ?? $currentTicket['title'],
'description' => $data['description'] ?? $currentTicket['description'],
'category' => $data['category'] ?? $currentTicket['category'],
'type' => $data['type'] ?? $currentTicket['type'],
'status' => $data['status'] ?? $currentTicket['status'],
'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority']
];
// Validate required fields
if (empty($updateData['title'])) {
return [
'success' => false,
'error' => 'Title cannot be empty',
'http_status' => 400
];
}
// Validate priority range
if ($updateData['priority'] < 1 || $updateData['priority'] > 5) {
return [
'success' => false,
'error' => 'Priority must be between 1 and 5',
'http_status' => 400
];
}
// Validate visibility BEFORE any DB write so a bad payload can't leave the
// ticket half-updated (core fields committed but request reported as failed).
$visibilityGroups = null;
if (isset($data['visibility'])) {
$visibilityGroups = $data['visibility_groups'] ?? null;
// Convert array to comma-separated string if needed
if (is_array($visibilityGroups)) {
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
}
// Authorization: only an admin or the ticket's creator may change
// visibility. Enforce only when the requested visibility actually
// differs so ordinary edits that re-send the same value aren't blocked.
$currentVisibility = $currentTicket['visibility'] ?? 'public';
$currentGroups = $currentTicket['visibility_groups'] ?? null;
$groupsProvided = array_key_exists('visibility_groups', $data);
$visibilityChanged = ($data['visibility'] !== $currentVisibility)
|| ($groupsProvided && (string)$visibilityGroups !== (string)$currentGroups);
if ($visibilityChanged) {
$isCreator = $this->userId !== null
&& (int)($currentTicket['created_by'] ?? 0) === (int)$this->userId;
if (!$this->isAdmin && !$isCreator) {
return [
'success' => false,
'error' => 'You do not have permission to change ticket visibility',
'http_status' => 403
];
}
}
// Internal visibility requires at least one group
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
return [
'success' => false,
'error' => 'Internal visibility requires at least one group to be specified',
'http_status' => 400
];
}
}
// Validate status transition using workflow model
if ($currentTicket['status'] !== $updateData['status']) {
$allowed = $this->workflowModel->isTransitionAllowed(
$currentTicket['status'],
$updateData['status'],
$this->isAdmin
);
if (!$allowed) {
return [
'success' => false,
'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status']
];
}
// Enforce requires_comment transitions server-side.
if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) {
$statusChangeComment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
if ($statusChangeComment === '') {
return [
'success' => false,
'error' => 'A comment is required for this status change',
'requires_comment' => true,
'http_status' => 400
];
}
}
}
// A comment accompanying a status change (required or optional) is
// persisted in the SAME transaction as the status update below, so
// a failure partway through can't leave an orphaned "reason"
// comment attached with no matching status change — the two
// previously ran as separate, non-transactional HTTP calls from
// the client (add_comment.php then update_ticket.php).
$statusChangeComment = $statusChangeComment ?? trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
$result = null;
$this->conn->begin_transaction();
try {
if ($statusChangeComment !== '' && $currentTicket['status'] !== $updateData['status']) {
$commentResult = $this->commentModel->addComment($id, [
'user_name' => $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? 'User',
'comment_text' => $statusChangeComment,
'markdown_enabled' => !empty($data['markdown_enabled']),
], $this->userId);
if (empty($commentResult['success'])) {
throw new Exception($commentResult['error'] ?? 'Failed to add comment');
}
}
// Update ticket with user tracking and optional optimistic locking
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
if (!$result['success']) {
throw new Exception($result['error'] ?? 'Failed to update ticket in database');
}
// Handle visibility update if provided (already validated above)
if (isset($data['visibility'])) {
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
if (!$visResult) {
throw new Exception('Failed to update ticket visibility');
}
}
$this->conn->commit();
} catch (Exception $e) {
$this->conn->rollback();
$response = ['success' => false, 'error' => $e->getMessage()];
if (is_array($result) && !empty($result['conflict'])) {
$response['conflict'] = true;
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
}
return $response;
}
if (isset($data['visibility']) && $this->userId) {
$this->auditLog->log(
$this->userId,
'update',
'ticket',
(string)$id,
[
'field' => 'visibility',
'from' => $currentTicket['visibility'] ?? 'public',
'to' => $data['visibility'],
'groups' => $visibilityGroups
]
);
}
// Log ticket update to audit log — only the changed fields (delta)
if ($this->userId) {
$trackFields = ['title', 'priority', 'status', 'description', 'category', 'type'];
$delta = [];
foreach ($trackFields as $field) {
$oldVal = (string)($currentTicket[$field] ?? '');
$newVal = (string)($updateData[$field] ?? '');
if ($oldVal !== $newVal) {
$delta[$field] = ['from' => $oldVal, 'to' => $newVal];
}
}
if (!empty($delta)) {
$this->auditLog->logTicketUpdate($this->userId, $id, $delta);
}
}
// Notify on status change (global notify list + watchers)
if ($currentTicket['status'] !== $updateData['status']) {
$changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null;
NotificationHelper::sendStatusChangeNotification(
$id,
$currentTicket['status'],
$updateData['status'],
$updateData['title'],
$changedBy,
$currentTicket['visibility'] ?? 'public'
);
NotificationHelper::notifyWatchers(
$this->conn,
$id,
$updateData['title'],
'status_changed',
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
(int)$this->userId,
$currentTicket['visibility'] ?? 'public'
);
}
return [
'success' => true,
'status' => $updateData['status'],
'priority' => $updateData['priority'],
'updated_at' => date('Y-m-d H:i:s'),
'message' => 'Ticket updated successfully',
'csrf_token' => $GLOBALS['newCsrfToken'] ?? null
];
}
}
+14 -102
View File
@@ -101,115 +101,27 @@ class TicketController
return;
}
// Handle visibility groups (comes as array from checkboxes)
$visibilityGroups = null;
if (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) {
$visibilityGroups = implode(',', array_map('trim', $_POST['visibility_groups']));
}
// Honor the posted status, validated against the app's canonical list
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
$status = $_POST['status'] ?? 'Open';
if (!in_array($status, $validStatuses, true)) {
$status = 'Open';
}
$ticketData = [
'title' => trim($_POST['title'] ?? ''),
// Validation (incl. required custom fields), creation, audit log,
// stats cache, custom field values, duplicate link and notification
// live in TicketCreationService so the MCP create_ticket tool runs
// the same code path.
require_once dirname(__DIR__) . '/services/TicketCreationService.php';
$result = TicketCreationService::create($this->conn, $currentUser ?? [], [
'title' => $_POST['title'] ?? '',
'description' => $_POST['description'] ?? '',
'priority' => $_POST['priority'] ?? '4',
'category' => $_POST['category'] ?? 'General',
'type' => $_POST['type'] ?? 'Issue',
'status' => $status,
'status' => $_POST['status'] ?? 'Open',
'visibility' => $_POST['visibility'] ?? 'public',
'visibility_groups' => $visibilityGroups,
'assigned_to' => !empty($_POST['assigned_to']) ? $_POST['assigned_to'] : null
];
// Validate input (server-side; form is novalidate)
if ($ticketData['title'] === '') {
$error = "Title is required";
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn; // Make $conn available to view
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
if (trim($ticketData['description']) === '') {
$error = "Description is required";
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn; // Make $conn available to view
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
// Custom fields applicable to the submitted category — validate
// is_required server-side (the form is novalidate, and a field
// hidden by the client-side category toggle must not silently
// bypass a requirement that applies to the category actually
// submitted).
$submittedCustomFields = is_array($_POST['custom_fields'] ?? null) ? $_POST['custom_fields'] : [];
$applicableFieldDefs = array_filter(
$allCustomFieldDefs,
fn($def) => $def['category'] === null || $def['category'] === $ticketData['category']
);
$customFieldsToSave = [];
foreach ($applicableFieldDefs as $def) {
$fieldId = (int)$def['field_id'];
$raw = $submittedCustomFields[$fieldId] ?? null;
$normalized = $def['field_type'] === 'checkbox'
? (!empty($raw) ? '1' : '0')
: (is_scalar($raw) ? trim((string)$raw) : '');
if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
$error = $def['field_label'] . ' is required';
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn;
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
if ($normalized !== '') {
$customFieldsToSave[$fieldId] = $normalized;
}
}
// Create ticket with user tracking
$result = $this->ticketModel->createTicket($ticketData, $userId);
'visibility_groups' => (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups']))
? $_POST['visibility_groups'] : null,
'assigned_to' => $_POST['assigned_to'] ?? null,
'custom_fields' => $_POST['custom_fields'] ?? [],
'link_duplicate_of' => $_POST['link_duplicate_of'] ?? '',
]);
if ($result['success']) {
// Log ticket creation to audit log
if (isset($GLOBALS['auditLog']) && $userId) {
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
}
// Ticket counts changed — invalidate the cached dashboard stats
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($this->conn))->invalidateCache();
// Persist custom field values for the fields applicable to
// this ticket's category
if (!empty($customFieldsToSave)) {
$this->customFieldModel->setValues($result['ticket_id'], $customFieldsToSave);
}
// Auto-link as duplicate if requested from create form
$linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
$depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by)
VALUES (?, ?, 'duplicates', ?)";
$depStmt = $this->conn->prepare($depSql);
$depStmt->bind_param("ssi", $result['ticket_id'], $linkDupOfRaw, $userId);
$depStmt->execute();
$depStmt->close();
}
// Send Matrix notification for new ticket
NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual');
// Redirect to the new ticket
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/" . $result['ticket_id']);
exit;
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env php
<?php
/**
* Failed Matrix Notification Retry Cron Job
*
* NotificationHelper::fire() queues a failed webhook post to
* notification_retry_queue instead of just logging and losing it. This
* script retries due rows with exponential backoff, up to each row's
* max_attempts, then leaves an exhausted row in place (not deleted) so it
* remains visible for manual investigation.
*
* Run this via cron every 5-10 minutes (see README.md's Cron Jobs section
* for the exact crontab line — the "every 5 minutes" syntax isn't repeated
* here since it would terminate this comment block early).
*/
// Prevent web access
if (php_sapi_name() !== 'cli') {
http_response_code(403);
exit('CLI access only');
}
chdir(dirname(__DIR__));
require_once 'config/config.php';
require_once 'helpers/Database.php';
require_once 'helpers/NotificationHelper.php';
function logMessage($message)
{
echo '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n";
}
/** Exponential backoff in minutes: 2, 4, 8, 16, 32, capped at 60. */
function nextAttemptDelayMinutes(int $attemptNumber): int
{
return min(60, 2 ** $attemptNumber);
}
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
if (empty($webhookUrl)) {
logMessage('MATRIX_WEBHOOK_URL not configured — nothing to retry, exiting.');
exit(0);
}
try {
$conn = Database::getConnection();
} catch (Exception $e) {
logMessage('FATAL ERROR: could not connect to database: ' . $e->getMessage());
exit(1);
}
// Process a bounded batch per run so one cron tick can't run indefinitely if
// the queue has backed up.
$batchLimit = 50;
$stmt = $conn->prepare(
"SELECT retry_id, payload, attempts, max_attempts
FROM notification_retry_queue
WHERE next_attempt_at <= NOW() AND attempts < max_attempts
ORDER BY retry_id ASC
LIMIT ?"
);
$stmt->bind_param('i', $batchLimit);
$stmt->execute();
$dueRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
if (empty($dueRows)) {
logMessage('No notifications due for retry.');
exit(0);
}
$succeeded = 0;
$failed = 0;
$exhausted = 0;
foreach ($dueRows as $row) {
$payload = json_decode($row['payload'], true);
if (!is_array($payload)) {
// Corrupt row — can't retry something unparseable. Remove it rather
// than retrying forever against a row that will never succeed.
$del = $conn->prepare("DELETE FROM notification_retry_queue WHERE retry_id = ?");
$del->bind_param('i', $row['retry_id']);
$del->execute();
$del->close();
logMessage("Discarded retry #{$row['retry_id']}: payload is not valid JSON");
continue;
}
$result = NotificationHelper::attemptDelivery($webhookUrl, $payload);
if ($result['success']) {
$del = $conn->prepare("DELETE FROM notification_retry_queue WHERE retry_id = ?");
$del->bind_param('i', $row['retry_id']);
$del->execute();
$del->close();
$succeeded++;
logMessage("Retry #{$row['retry_id']} succeeded (attempt " . ((int)$row['attempts'] + 1) . ')');
continue;
}
$newAttempts = (int)$row['attempts'] + 1;
if ($newAttempts >= (int)$row['max_attempts']) {
// Exhausted: leave the row (attempts is now == max_attempts, so the
// WHERE clause above naturally excludes it from future runs) rather
// than deleting it, so it stays visible for manual investigation.
$upd = $conn->prepare(
"UPDATE notification_retry_queue SET attempts = ?, last_error = ? WHERE retry_id = ?"
);
$upd->bind_param('isi', $newAttempts, $result['error'], $row['retry_id']);
$upd->execute();
$upd->close();
$exhausted++;
logMessage("Retry #{$row['retry_id']} exhausted after {$newAttempts} attempts: {$result['error']}");
continue;
}
$delayMinutes = nextAttemptDelayMinutes($newAttempts);
$upd = $conn->prepare(
"UPDATE notification_retry_queue
SET attempts = ?, last_error = ?, next_attempt_at = DATE_ADD(NOW(), INTERVAL ? MINUTE)
WHERE retry_id = ?"
);
$upd->bind_param('isii', $newAttempts, $result['error'], $delayMinutes, $row['retry_id']);
$upd->execute();
$upd->close();
$failed++;
logMessage("Retry #{$row['retry_id']} failed (attempt {$newAttempts}), next attempt in {$delayMinutes}m: {$result['error']}");
}
logMessage("Done: {$succeeded} succeeded, {$failed} rescheduled, {$exhausted} exhausted (of " . count($dueRows) . ' processed)');
+31
View File
@@ -0,0 +1,31 @@
<?php
/**
* Who may use Tinker Tickets at all. Shared by the web login
* (AuthMiddleware, from Authelia's Remote-Groups header) and the MCP endpoint
* (from the OAuth access token's groups claim), so both entry points enforce
* one rule instead of two copies that can drift apart.
*/
class AccessPolicy
{
/** Membership in any of these grants access. */
private const REQUIRED_GROUPS = ['admin', 'employee'];
/**
* @param string $groups Comma-separated group names (Remote-Groups format)
*/
public static function hasAppAccess(string $groups): bool
{
if ($groups === '') {
return false;
}
// Filter to safe characters only to prevent header injection attacks
$userGroups = array_filter(
array_map('trim', explode(',', strtolower($groups))),
fn($g) => preg_match('/^[a-z0-9_\-]+$/', $g)
);
return !empty(array_intersect($userGroups, self::REQUIRED_GROUPS));
}
}
+60 -14
View File
@@ -7,13 +7,16 @@ class NotificationHelper
{
// ─── Internal: fire a webhook ─────────────────────────────────────────────
private static function fire(array $payload): void
/**
* POST a payload to the configured Matrix webhook and report the raw
* result. Shared by fire() (best-effort, queues on failure) and
* cron/retry_failed_notifications.php (retries a previously-queued
* payload) so both use identical request handling.
*
* @return array{success: bool, http_code: ?int, error: ?string}
*/
public static function attemptDelivery(string $webhookUrl, array $payload): array
{
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
if (empty($webhookUrl)) {
return;
}
$ch = curl_init($webhookUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POST, 1);
@@ -21,10 +24,11 @@ class NotificationHelper
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
// A slow-but-not-fully-hung hookshot endpoint could otherwise add up
// to the full CURLOPT_TIMEOUT per fire() call, and a single request
// can call fire() (via notifyWatchers/sendCommentNotification/etc.)
// more than once sequentially — capping just the connect phase keeps
// that from stacking into tens of seconds of added latency.
// to the full CURLOPT_TIMEOUT per call, and a single request can
// trigger more than one notification sequentially (via
// notifyWatchers/sendCommentNotification/etc.) — capping just the
// connect phase keeps that from stacking into tens of seconds of
// added latency.
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);
$response = curl_exec($ch);
@@ -32,12 +36,54 @@ class NotificationHelper
$curlError = curl_error($ch);
curl_close($ch);
$id = $payload['ticket_id'] ?? '?';
if ($curlError) {
error_log("Matrix webhook cURL error for ticket #{$id}: {$curlError}");
} elseif ($httpCode < 200 || $httpCode >= 300) {
error_log("Matrix webhook failed for ticket #{$id}. HTTP {$httpCode}: {$response}");
return ['success' => false, 'http_code' => null, 'error' => $curlError];
}
if ($httpCode < 200 || $httpCode >= 300) {
return ['success' => false, 'http_code' => $httpCode, 'error' => "HTTP {$httpCode}: {$response}"];
}
return ['success' => true, 'http_code' => $httpCode, 'error' => null];
}
/**
* Persist a failed payload for later retry by
* cron/retry_failed_notifications.php. Best-effort: a DB failure here
* must not throw back into the original (already-failed) notification
* attempt — it just means this particular failure isn't retried, no
* worse than the pre-existing behavior.
*/
private static function queueForRetry(array $payload, string $error): void
{
try {
require_once dirname(__DIR__) . '/helpers/Database.php';
$conn = Database::getConnection();
$stmt = $conn->prepare(
"INSERT INTO notification_retry_queue (payload, last_error) VALUES (?, ?)"
);
$payloadJson = json_encode($payload);
$stmt->bind_param("ss", $payloadJson, $error);
$stmt->execute();
$stmt->close();
} catch (Throwable $e) {
error_log('NotificationHelper: failed to queue notification for retry: ' . $e->getMessage());
}
}
private static function fire(array $payload): void
{
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
if (empty($webhookUrl)) {
return;
}
$result = self::attemptDelivery($webhookUrl, $payload);
if ($result['success']) {
return;
}
$id = $payload['ticket_id'] ?? '?';
error_log("Matrix webhook failed for ticket #{$id}: {$result['error']}");
self::queueForRetry($payload, $result['error']);
}
private static function notifyUsers(): array
+13 -3
View File
@@ -388,9 +388,19 @@ switch (true) {
GROUP BY user_id
) cm ON u.user_id = cm.user_id
LEFT JOIN (
SELECT assigned_to, COUNT(*) as tickets_assigned
FROM tickets
WHERE DATE(created_at) BETWEEN ? AND ?
-- Assignment date, not ticket creation date: a ticket created
-- outside the range but assigned within it should count, and
-- one created in-range but assigned later shouldn't (until it
-- is). Derived from audit_log's 'assign' events since tickets
-- has no assigned_at column; COUNT(DISTINCT ...) so a ticket
-- reassigned more than once to the same user in-range still
-- counts once.
SELECT
CAST(JSON_UNQUOTE(JSON_EXTRACT(details, '$.assigned_to')) AS UNSIGNED) as assigned_to,
COUNT(DISTINCT entity_id) as tickets_assigned
FROM audit_log
WHERE action_type = 'assign' AND entity_type = 'ticket'
AND DATE(created_at) BETWEEN ? AND ?
GROUP BY assigned_to
) ta ON u.user_id = ta.assigned_to
LEFT JOIN (
+159
View File
@@ -0,0 +1,159 @@
<?php
/**
* MCP endpoint (Streamable HTTP), OAuth-protected by Authelia. See issue #111.
*
* Serves /mcp and the RFC 9728 Protected Resource Metadata paths (nginx routes
* all of them here). This is the ONLY file allowed to load vendor/autoload.php.
*
* Identity comes exclusively from the validated access token. Never read
* Remote-User / Remote-* headers or $_SESSION for identity here: this location
* is exempt from Authelia forward-auth at the proxy, so those headers are
* client-controlled on this path.
*/
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api', false);
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/helpers/AccessPolicy.php';
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/StatsModel.php';
require_once dirname(__DIR__) . '/controllers/ApiTicketController.php';
require_once dirname(__DIR__) . '/services/TicketCreationService.php';
require_once dirname(__DIR__) . '/services/CommentService.php';
require_once dirname(__DIR__) . '/services/AssignmentService.php';
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Mcp\Server;
use Mcp\Server\Session\FileSessionStore;
use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware;
use Mcp\Server\Transport\Http\Middleware\CorsMiddleware;
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
use Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware;
use Mcp\Server\Transport\Http\OAuth\JwksProvider;
use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator;
use Mcp\Server\Transport\Http\OAuth\OidcDiscovery;
use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata;
use Mcp\Server\Transport\StreamableHttpTransport;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Psr16Cache;
use TinkerTickets\Mcp\Auth\IdentityMiddleware;
use TinkerTickets\Mcp\Auth\ToolScopeMiddleware;
use TinkerTickets\Mcp\ToolCatalog;
$resourceUrl = $GLOBALS['config']['MCP_RESOURCE_URL'] ?? null;
$issuer = $GLOBALS['config']['MCP_OAUTH_ISSUER'] ?? null;
if (empty($resourceUrl) || empty($issuer)) {
http_response_code(503);
header('Content-Type: application/json');
echo json_encode(['error' => 'MCP endpoint is not configured (MCP_RESOURCE_URL / MCP_OAUTH_ISSUER)']);
exit;
}
$psr17 = new Psr17Factory();
$request = (new ServerRequestCreator($psr17, $psr17, $psr17, $psr17))->fromGlobals();
// ServerRequestCreator adds Host both from the URI and from the request
// headers, so getHeaderLine('Host') comes back as "h, h" under PHP-FPM, which
// the DNS-rebinding check below then rejects. Collapse to the single value the
// client actually sent.
$clientHost = $request->getHeader('Host')[0] ?? '';
if ($clientHost !== '') {
$request = $request->withHeader('Host', $clientHost);
}
// TLS terminates at the reverse proxy, so PHP sees plain http and a Host header
// the client controls. The SDK derives the resource_metadata URL in its 401
// challenge from the request URI, so pin scheme/host/port to the configured
// canonical URL instead of anything the request claims. preserveHost keeps
// the client's real Host header for the DNS-rebinding check below; without
// it withUri() would overwrite Host and make that check a no-op.
$canonical = parse_url($resourceUrl);
$request = $request->withUri(
$request->getUri()
->withScheme($canonical['scheme'])
->withHost($canonical['host'])
->withPort($canonical['port'] ?? null),
true
);
// Cache OIDC discovery + JWKS so every MCP call isn't two extra round trips to
// Authelia. Outside the webroot on purpose.
$cache = new Psr16Cache(new FilesystemAdapter('tinker_mcp', 3600, sys_get_temp_dir() . '/tinker_mcp_cache'));
$validator = new JwtTokenValidator(
issuer: $issuer,
audience: $resourceUrl,
jwksProvider: new JwksProvider(new OidcDiscovery(cache: $cache), cache: $cache),
// Authelia puts scopes in an `scp` array, not the standard `scope` string
// (verified in #111 phase 1). With the default, every scope check fails.
scopeClaim: 'scp',
);
$resourcePath = $canonical['path'] ?? '';
$metadata = new ProtectedResourceMetadata(
authorizationServers: [$issuer],
scopesSupported: ['tickets:read', 'tickets:write'],
resource: $resourceUrl,
resourceName: 'Tinker Tickets',
// RFC 9728 path-suffixed form first (used in the WWW-Authenticate
// challenge), plus the root form some clients probe.
metadataPaths: array_values(array_unique([
'/.well-known/oauth-protected-resource' . $resourcePath,
'/.well-known/oauth-protected-resource',
])),
);
$conn = Database::getConnection();
$server = ToolCatalog::register(
Server::builder()
->setServerInfo('Tinker Tickets', '1.0.0')
// Handshake-era clients (pre-2026-07-28) still use protocol sessions.
->setSession(new FileSessionStore(sys_get_temp_dir() . '/tinker_mcp_sessions')),
$conn
)->build();
$transport = new StreamableHttpTransport(
$request,
middleware: [
// CORS: SDK default (no Access-Control-Allow-Origin, so cross-origin
// browser calls are refused). Host allowlist: only the canonical
// hostname, which also refuses direct-by-IP access.
new CorsMiddleware(),
new DnsRebindingProtectionMiddleware([$canonical['host']]),
new ProtectedResourceMetadataMiddleware($metadata),
new AuthorizationMiddleware($validator, $metadata),
// Identity comes from the validated token's server-side request
// attributes, never from client-writable JSON-RPC `_meta` (so the
// SDK's OAuthRequestMetaMiddleware is intentionally not used).
new IdentityMiddleware($conn, $psr17, $psr17),
new ToolScopeMiddleware(
$psr17,
$canonical['scheme'] . '://' . $canonical['host'] . $metadata->getPrimaryMetadataPath()
),
],
);
try {
$response = $server->run($transport);
} catch (\Throwable $e) {
error_log('mcp/server.php: ' . $e::class . ': ' . $e->getMessage());
$response = $psr17->createResponse(500)
->withHeader('Content-Type', 'application/json')
->withBody($psr17->createStream(json_encode([
'jsonrpc' => '2.0',
'id' => null,
'error' => ['code' => -32603, 'message' => 'Internal error'],
])));
}
(new SapiEmitter())->emit($response);
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace TinkerTickets\Mcp\Auth;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Maps a validated access token to a Tinker Tickets user, applying exactly
* the same rules as the web login (AuthMiddleware): the shared
* AccessPolicy::hasAppAccess() group check, then
* UserModel::syncUserFromAuthelia() to create/update the user row and derive
* is_admin from groups.
*
* Must run after the SDK's AuthorizationMiddleware, which has already
* verified the token's signature, issuer, audience and expiry and attached
* its claims as `oauth.claims` / `oauth.scopes` request attributes.
*/
final class IdentityMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly \mysqli $conn,
private readonly ResponseFactoryInterface $responseFactory,
private readonly StreamFactoryInterface $streamFactory,
) {
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$claims = $request->getAttribute('oauth.claims');
$scopes = $request->getAttribute('oauth.scopes') ?? [];
if (!is_array($claims)) {
// Only reachable if the stack is misordered; never serve anonymously.
return $this->deny(500, 'Identity unavailable');
}
$username = is_string($claims['preferred_username'] ?? null) ? trim($claims['preferred_username']) : '';
if ($username === '') {
return $this->deny(403, 'Access token has no preferred_username claim');
}
$groupsClaim = $claims['groups'] ?? [];
$groups = implode(',', array_filter(
is_array($groupsClaim) ? $groupsClaim : [$groupsClaim],
'is_string'
));
if (!\AccessPolicy::hasAppAccess($groups)) {
return $this->deny(403, 'Your account is not permitted to use Tinker Tickets');
}
$user = (new \UserModel($this->conn))->syncUserFromAuthelia(
$username,
is_string($claims['name'] ?? null) ? $claims['name'] : '',
is_string($claims['email'] ?? null) ? $claims['email'] : '',
$groups
);
McpIdentity::set($user, array_values(array_filter($scopes, 'is_string')));
return $handler->handle($request);
}
private function deny(int $status, string $message): ResponseInterface
{
return $this->responseFactory->createResponse($status)
->withHeader('Content-Type', 'application/json')
->withBody($this->streamFactory->createStream(json_encode([
'jsonrpc' => '2.0',
'id' => null,
'error' => ['code' => -32001, 'message' => $message],
])));
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace TinkerTickets\Mcp\Auth;
/**
* The Tinker Tickets user an MCP request runs as, plus the token's scopes.
*
* Set once per HTTP request by IdentityMiddleware from the *validated* access
* token, which is held as server-side PSR-7 request attributes. It is
* deliberately NOT read from the JSON-RPC `_meta` field: clients can write
* to `_meta`, and the SDK's OAuthRequestMetaMiddleware only overwrites the
* keys the validator happens to set.
*
* Static state is request-scoped here: PHP-FPM serves one HTTP request per
* process lifecycle, and the whole MCP call (including SDK fibers) runs
* inside it.
*/
final class McpIdentity
{
public const SCOPE_READ = 'tickets:read';
public const SCOPE_WRITE = 'tickets:write';
private static ?array $user = null;
/** @var list<string> */
private static array $scopes = [];
/**
* @param array<string, mixed> $user
* @param list<string> $scopes
*/
public static function set(array $user, array $scopes): void
{
self::$user = $user;
self::$scopes = $scopes;
}
/**
* @return array<string, mixed>
*/
public static function user(): array
{
if (self::$user === null) {
// Unreachable if the middleware stack is intact; fail closed.
throw new \LogicException('MCP identity requested before IdentityMiddleware ran');
}
return self::$user;
}
/** tickets:write implies tickets:read (scope hierarchy, MCP spec §Scope Challenge Handling). */
public static function canRead(array $scopes): bool
{
return in_array(self::SCOPE_READ, $scopes, true) || in_array(self::SCOPE_WRITE, $scopes, true);
}
public static function canWrite(array $scopes): bool
{
return in_array(self::SCOPE_WRITE, $scopes, true);
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace TinkerTickets\Mcp\Auth;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TinkerTickets\Mcp\ToolCatalog;
/**
* Per-operation scope enforcement, before anything is dispatched.
*
* - Lifecycle messages (initialize, ping, notifications/*, server/discover)
* need only a valid token.
* - tools/call on a write tool needs tickets:write.
* - Everything else (tools/list, read tools, ...) needs tickets:read, which
* tickets:write implies.
*
* Denials use the MCP spec's step-up challenge: HTTP 403 +
* WWW-Authenticate: Bearer error="insufficient_scope", listing every scope
* the request needs in one go.
*/
final class ToolScopeMiddleware implements MiddlewareInterface
{
private const LIFECYCLE_METHODS = ['initialize', 'ping', 'server/discover'];
public function __construct(
private readonly ResponseFactoryInterface $responseFactory,
private readonly string $resourceMetadataUrl,
) {
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if ($request->getMethod() !== 'POST') {
return $handler->handle($request);
}
$payload = json_decode((string)$request->getBody(), true);
$request->getBody()->rewind();
if (!is_array($payload)) {
return $handler->handle($request); // the SDK answers malformed JSON-RPC itself
}
$messages = array_is_list($payload) ? $payload : [$payload];
$needWrite = false;
$needRead = false;
foreach ($messages as $message) {
if (!is_array($message) || !is_string($message['method'] ?? null)) {
continue; // responses to server->client requests carry no method
}
$method = $message['method'];
if (in_array($method, self::LIFECYCLE_METHODS, true) || str_starts_with($method, 'notifications/')) {
continue;
}
if ($method === 'tools/call' && ToolCatalog::isWriteTool((string)($message['params']['name'] ?? ''))) {
$needWrite = true;
} else {
$needRead = true;
}
}
$scopes = $request->getAttribute('oauth.scopes') ?? [];
if ($needWrite && !McpIdentity::canWrite($scopes)) {
return $this->insufficient(McpIdentity::SCOPE_WRITE, 'This operation requires the tickets:write scope.');
}
if ($needRead && !McpIdentity::canRead($scopes)) {
return $this->insufficient(
$needWrite ? McpIdentity::SCOPE_WRITE : McpIdentity::SCOPE_READ,
'This operation requires the tickets:read scope.'
);
}
return $handler->handle($request);
}
private function insufficient(string $scope, string $description): ResponseInterface
{
return $this->responseFactory->createResponse(403)->withHeader(
'WWW-Authenticate',
sprintf(
'Bearer error="insufficient_scope", scope="%s", resource_metadata="%s", error_description="%s"',
$scope,
$this->resourceMetadataUrl,
$description
)
);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace TinkerTickets\Mcp;
use Mcp\Schema\ToolAnnotations;
use Mcp\Server\Builder;
use TinkerTickets\Mcp\Tools\TicketReadTools;
use TinkerTickets\Mcp\Tools\TicketWriteTools;
/**
* The single list of MCP tools: registration, plus which ones need the
* tickets:write scope (read by ToolScopeMiddleware). Keeping both here means
* a new write tool can't be registered without also being scope-gated.
*/
final class ToolCatalog
{
/** Tool names that mutate data and require tickets:write. */
private const WRITE_TOOLS = ['create_ticket', 'add_comment', 'update_status', 'assign_ticket'];
public static function isWriteTool(string $name): bool
{
return in_array($name, self::WRITE_TOOLS, true);
}
public static function register(Builder $builder, \mysqli $conn): Builder
{
$read = new TicketReadTools($conn);
$write = new TicketWriteTools($conn);
$readOnly = new ToolAnnotations(readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false);
// Writes change tickets (and notify people) but never delete anything.
$additive = new ToolAnnotations(readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false);
$update = new ToolAnnotations(readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false);
return $builder
->addTool([$read, 'searchTickets'], 'search_tickets', 'Search tickets', null, $readOnly)
->addTool([$read, 'getTicket'], 'get_ticket', 'Get ticket', null, $readOnly)
->addTool([$write, 'createTicket'], 'create_ticket', 'Create ticket', null, $additive)
->addTool([$write, 'addComment'], 'add_comment', 'Add comment', null, $additive)
->addTool([$write, 'updateStatus'], 'update_status', 'Update ticket status', null, $update)
->addTool([$write, 'assignTicket'], 'assign_ticket', 'Assign ticket', null, $update);
}
}
+183
View File
@@ -0,0 +1,183 @@
<?php
namespace TinkerTickets\Mcp\Tools;
use Mcp\Exception\ToolCallException;
use TinkerTickets\Mcp\Auth\McpIdentity;
/**
* Read-only tools. Everything goes through the same model methods and
* visibility checks the web UI uses, as the signed-in user, so MCP can never
* show more than that user could see in their browser.
*/
final class TicketReadTools
{
private const MAX_COMMENTS = 200;
public function __construct(private readonly \mysqli $conn)
{
}
/**
* Search and list tickets you can see (same visibility rules as the web UI).
*
* @param string|null $query Free-text search over ticket titles and descriptions.
* @param string|null $status Comma-separated statuses, e.g. "Open,In Progress". Omit for every status except Closed; use "all" for every status.
* @param int|null $priority Exact priority: 1 (critical) to 5 (minimal).
* @param string|null $category Exact category name.
* @param string|null $assignee "me", "unassigned", or a username.
* @param int $page Page number, starting at 1.
* @param int $limit Tickets per page, 1-50.
*
* @return array<string, mixed>
*/
public function searchTickets(
?string $query = null,
?string $status = null,
?int $priority = null,
?string $category = null,
?string $assignee = null,
int $page = 1,
int $limit = 20,
): array {
$user = McpIdentity::user();
$allStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
if ($status === null || trim($status) === '') {
$statusFilter = implode(',', array_filter($allStatuses, fn($s) => $s !== 'Closed'));
} elseif (strtolower(trim($status)) === 'all') {
$statusFilter = null;
} else {
$requested = array_map('trim', explode(',', $status));
$unknown = array_diff($requested, $allStatuses);
if ($unknown) {
throw new ToolCallException('Unknown status: ' . implode(', ', $unknown)
. '. Valid statuses: ' . implode(', ', $allStatuses));
}
$statusFilter = implode(',', $requested);
}
$filters = [];
if ($priority !== null) {
if ($priority < 1 || $priority > 5) {
throw new ToolCallException('priority must be between 1 and 5');
}
$filters['priority_min'] = $priority;
$filters['priority_max'] = $priority;
}
if ($assignee !== null && trim($assignee) !== '') {
$filters['assigned_to'] = $this->resolveAssigneeFilter(trim($assignee), $user);
}
$page = max(1, $page);
$limit = min(50, max(1, $limit));
$result = (new \TicketModel($this->conn))->getAllTickets(
$page,
$limit,
$statusFilter,
'updated_at',
'desc',
($category !== null && trim($category) !== '') ? trim($category) : null,
null,
($query !== null && trim($query) !== '') ? trim($query) : null,
$filters,
$user
);
return [
'tickets' => array_map([$this, 'summarize'], $result['tickets']),
'page' => $result['current_page'],
'pages' => $result['pages'],
'total' => $result['total'],
];
}
/**
* Get one ticket's full details and its comments.
*
* @param string $ticket_id The ticket ID (digits only, e.g. "123456789").
* @param bool $include_comments Include the ticket's comments, newest first.
*
* @return array<string, mixed>
*/
public function getTicket(string $ticket_id, bool $include_comments = true): array
{
$user = McpIdentity::user();
$ticketModel = new \TicketModel($this->conn);
$ticketId = trim($ticket_id);
$ticket = preg_match('/^\d+$/', $ticketId) ? $ticketModel->getTicketById($ticketId) : null;
// Same "not found" for missing and not-visible, so a restricted
// ticket's existence isn't revealed.
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $user)) {
throw new ToolCallException("Ticket {$ticketId} not found");
}
$details = $this->summarize($ticket) + [
'description' => $ticket['description'] ?? '',
'visibility_groups' => $ticket['visibility_groups'] ?? null,
'closed_at' => $ticket['closed_at'] ?? null,
'updated_by' => $ticket['updater_display_name'] ?? $ticket['updater_username'] ?? null,
];
if ($include_comments) {
$comments = (new \CommentModel($this->conn))->getCommentsByTicketId($ticketId, false);
$details['comment_count'] = count($comments);
$details['comments'] = array_map(fn(array $c) => [
'comment_id' => (int)$c['comment_id'],
'author' => $c['display_name'] ?? $c['username'] ?? $c['user_name'] ?? null,
'created_at' => $c['created_at'] ?? null,
'reply_to' => isset($c['parent_comment_id']) ? (int)$c['parent_comment_id'] : null,
'text' => $c['comment_text'] ?? '',
], array_slice($comments, 0, self::MAX_COMMENTS));
if (count($comments) > self::MAX_COMMENTS) {
$details['comments_truncated'] = true;
}
}
return $details;
}
/**
* @param array<string, mixed> $user
*/
private function resolveAssigneeFilter(string $assignee, array $user): int|string
{
$lower = strtolower($assignee);
if ($lower === 'me') {
return (int)$user['user_id'];
}
if ($lower === 'unassigned') {
return 'unassigned';
}
$match = (new \UserModel($this->conn))->getUserByUsername($assignee);
if (!$match) {
throw new ToolCallException("Unknown user: {$assignee}");
}
return (int)$match['user_id'];
}
/**
* @param array<string, mixed> $t
*
* @return array<string, mixed>
*/
private function summarize(array $t): array
{
return [
'ticket_id' => (string)$t['ticket_id'],
'title' => $t['title'] ?? '',
'status' => $t['status'] ?? null,
'priority' => isset($t['priority']) ? (int)$t['priority'] : null,
'category' => $t['category'] ?? null,
'type' => $t['type'] ?? null,
'visibility' => $t['visibility'] ?? 'public',
'assigned_to' => $t['assigned_display_name'] ?? $t['assigned_username'] ?? null,
'created_by' => $t['creator_display_name'] ?? $t['creator_username'] ?? null,
'created_at' => $t['created_at'] ?? null,
'updated_at' => $t['updated_at'] ?? null,
'url' => \UrlHelper::ticketUrl((string)$t['ticket_id']),
];
}
}
+216
View File
@@ -0,0 +1,216 @@
<?php
namespace TinkerTickets\Mcp\Tools;
use Mcp\Exception\ToolCallException;
use TinkerTickets\Mcp\Auth\McpIdentity;
/**
* Write tools. Each one is a thin adapter over the exact code path the web UI
* uses (TicketCreationService, CommentService, ApiTicketController,
* AssignmentService), run as the signed-in user, so permissions, workflow
* rules, audit entries, notifications and stats-cache invalidation are
* identical. Gated by tickets:write in ToolScopeMiddleware (see ToolCatalog).
*/
final class TicketWriteTools
{
private const VISIBILITIES = ['public', 'internal', 'confidential'];
public function __construct(private readonly \mysqli $conn)
{
}
/**
* Create a new ticket as you. It starts in the Open status.
*
* @param string $title Short summary of the issue.
* @param string $description Full description (markdown supported).
* @param int $priority 1 (critical) to 5 (minimal). Default 4.
* @param string $category Ticket category, e.g. "General", "Hardware", "Network".
* @param string $type Ticket type, e.g. "Issue", "Task", "Request".
* @param string $visibility "public" (everyone), "internal" (only the listed groups), or "confidential" (only you, the assignee and admins).
* @param string|null $visibility_groups Comma-separated group names; required when visibility is "internal".
* @param string|null $assignee Username to assign to, or "me". Omit to leave unassigned.
*
* @return array<string, mixed>
*/
public function createTicket(
string $title,
string $description,
int $priority = 4,
string $category = 'General',
string $type = 'Issue',
string $visibility = 'public',
?string $visibility_groups = null,
?string $assignee = null,
): array {
$user = McpIdentity::user();
// The web form constrains these with dropdowns; an API caller can send
// anything, so validate them here before handing off.
if ($priority < 1 || $priority > 5) {
throw new ToolCallException('priority must be between 1 and 5');
}
if (!in_array($visibility, self::VISIBILITIES, true)) {
throw new ToolCallException('visibility must be one of: ' . implode(', ', self::VISIBILITIES));
}
$assignedTo = null;
if ($assignee !== null && trim($assignee) !== '') {
$assignedTo = $this->resolveUserId(trim($assignee), $user);
}
$result = \TicketCreationService::create($this->conn, $user, [
'title' => $title,
'description' => $description,
'priority' => (string)$priority,
'category' => $category,
'type' => $type,
'status' => 'Open',
'visibility' => $visibility,
'visibility_groups' => $visibility_groups,
'assigned_to' => $assignedTo,
]);
if (!$result['success']) {
throw new ToolCallException($result['error']);
}
return [
'ticket_id' => (string)$result['ticket_id'],
'url' => \UrlHelper::ticketUrl((string)$result['ticket_id']),
];
}
/**
* Post a comment on a ticket as you. @username mentions notify that user.
*
* @param string $ticket_id The ticket ID.
* @param string $text The comment text.
* @param bool $markdown Render the comment as markdown.
* @param int|null $reply_to comment_id of the comment you are replying to, if any.
*
* @return array<string, mixed>
*/
public function addComment(string $ticket_id, string $text, bool $markdown = true, ?int $reply_to = null): array
{
$user = McpIdentity::user();
$result = \CommentService::addComment($this->conn, $user, [
'ticket_id' => $ticket_id,
'comment_text' => $text,
'markdown_enabled' => $markdown,
'parent_comment_id' => $reply_to,
]);
if (empty($result['success'])) {
throw new ToolCallException($this->errorMessage($result, $ticket_id));
}
return [
'comment_id' => (int)$result['comment_id'],
'ticket_id' => trim($ticket_id),
'mentions' => $result['mentions'] ?? [],
];
}
/**
* Change a ticket's status. Transitions follow the Workflow Designer rules;
* some transitions (e.g. closing) require a comment, which is posted as the reason.
*
* @param string $ticket_id The ticket ID.
* @param string $status New status, e.g. "Open", "Pending", "In Progress", "Closed".
* @param string|null $comment Reason for the change; required for transitions that need one.
*
* @return array<string, mixed>
*/
public function updateStatus(string $ticket_id, string $status, ?string $comment = null): array
{
$user = McpIdentity::user();
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
if (!in_array($status, $validStatuses, true)) {
throw new ToolCallException('Unknown status. Valid statuses: ' . implode(', ', $validStatuses));
}
$ticketId = trim($ticket_id);
if (!ctype_digit($ticketId)) {
throw new ToolCallException("Ticket {$ticketId} not found");
}
$controller = new \ApiTicketController($this->conn, (int)$user['user_id'], !empty($user['is_admin']), $user);
$data = ['status' => $status];
if ($comment !== null && trim($comment) !== '') {
$data['comment'] = $comment;
$data['markdown_enabled'] = true;
}
$result = $controller->update($ticketId, $data);
if (empty($result['success'])) {
if (!empty($result['requires_comment'])) {
throw new ToolCallException('This status change requires a comment explaining why. Call update_status again with a comment.');
}
throw new ToolCallException($this->errorMessage($result, $ticketId));
}
// api/update_ticket.php invalidates the dashboard stats cache after a
// successful update (outside the controller); do the same here.
(new \StatsModel($this->conn))->invalidateCache();
return ['ticket_id' => $ticketId, 'status' => $result['status']];
}
/**
* Assign a ticket to someone, or unassign it. Only admins, the ticket's
* creator, or its current assignee may do this.
*
* @param string $ticket_id The ticket ID.
* @param string $assignee Username to assign to, "me", or "unassigned".
*
* @return array<string, mixed>
*/
public function assignTicket(string $ticket_id, string $assignee): array
{
$user = McpIdentity::user();
$target = trim($assignee);
$assignedTo = strtolower($target) === 'unassigned' || $target === ''
? null
: $this->resolveUserId($target, $user);
$result = \AssignmentService::assign($this->conn, $user, [
'ticket_id' => $ticket_id,
'assigned_to' => $assignedTo,
]);
if (empty($result['success'])) {
throw new ToolCallException($this->errorMessage($result, $ticket_id));
}
return ['ticket_id' => trim($ticket_id), 'assigned_to' => $assignedTo === null ? null : $target];
}
/**
* @param array<string, mixed> $user
*/
private function resolveUserId(string $username, array $user): int
{
if (strtolower($username) === 'me') {
return (int)$user['user_id'];
}
$match = (new \UserModel($this->conn))->getUserByUsername($username);
if (!$match) {
throw new ToolCallException("Unknown user: {$username}");
}
return (int)$match['user_id'];
}
/**
* Map a shared-service failure to a tool error. A ticket the user can't
* see reads as "not found" (like get_ticket), never "access denied", so a
* restricted ticket's existence isn't revealed.
*
* @param array<string, mixed> $result
*/
private function errorMessage(array $result, string $ticketId): string
{
$error = (string)($result['error'] ?? 'Request failed');
if (in_array($error, ['Access denied', 'Ticket not found', 'Invalid ticket ID', 'Ticket ID required'], true)) {
return 'Ticket ' . trim($ticketId) . ' not found';
}
return $error;
}
}
+2 -1
View File
@@ -37,6 +37,7 @@ class ApiKeyAuth
{
$this->keyContext = [
'scope' => $keyData['scope'] ?? 'read_write',
'see_all_visibility' => !empty($keyData['see_all_visibility']),
'key_name' => $keyData['key_name'] ?? null,
'created_by' => $keyData['created_by'] ?? null,
'api_key_id' => $keyData['api_key_id'] ?? null,
@@ -46,7 +47,7 @@ class ApiKeyAuth
/**
* Get the context of the authenticated API key.
*
* @return array|null ['scope', 'key_name', 'created_by', 'api_key_id'] or null
* @return array|null ['scope', 'see_all_visibility', 'key_name', 'created_by', 'api_key_id'] or null
*/
public function getKeyContext(): ?array
{
+2 -15
View File
@@ -263,21 +263,8 @@ class AuthMiddleware
*/
private function checkGroupAccess($groups)
{
if (empty($groups)) {
return false;
}
// Check for admin or employee group membership
// Filter to safe characters only to prevent header injection attacks
$userGroups = array_filter(
array_map('trim', explode(',', strtolower($groups))),
function ($g) {
return preg_match('/^[a-z0-9_\-]+$/', $g);
}
);
$requiredGroups = ['admin', 'employee'];
return !empty(array_intersect($userGroups, $requiredGroups));
require_once dirname(__DIR__) . '/helpers/AccessPolicy.php';
return AccessPolicy::hasAppAccess((string)($groups ?? ''));
}
/**
+14
View File
@@ -0,0 +1,14 @@
-- Add a nullable thumbnail_filename column to ticket_attachments so an image
-- upload can store a separately-generated, resized preview alongside the
-- full-size original. NULL means no thumbnail exists (non-image, GD
-- unavailable at upload time, or an attachment uploaded before this existed)
-- and callers fall back to the full-size original.
--
-- scripts/cleanup_orphan_uploads.php's orphan lookup is updated in the same
-- change to also match thumbnail_filename, so generated thumbnails aren't
-- swept up as orphans.
--
-- Safe to re-run.
ALTER TABLE `ticket_attachments`
ADD COLUMN IF NOT EXISTS `thumbnail_filename` varchar(255) DEFAULT NULL AFTER `filename`;
@@ -0,0 +1,13 @@
-- Restrict Bearer API keys to public-visibility tickets by default (#70).
--
-- Previously any 'read'-scope key bypassed ticket visibility entirely —
-- Confidential and Internal tickets were readable by any key, regardless
-- of who it was issued to. see_all_visibility is an explicit opt-in an
-- admin sets per-key when a key genuinely needs to see non-public tickets;
-- it defaults to 0 (public-only) for both new and existing keys, since the
-- prior blanket-access behavior is the thing being restricted.
--
-- Safe to re-run.
ALTER TABLE `api_keys`
ADD COLUMN IF NOT EXISTS `see_all_visibility` tinyint(1) NOT NULL DEFAULT 0 AFTER `scope`;
@@ -0,0 +1,20 @@
-- Queue for Matrix webhook notifications that failed to send (#78).
--
-- NotificationHelper::fire() previously logged a failed webhook post via
-- error_log() only, with no retry and no persistent record — once a
-- notification failed, it was gone. Failed payloads are now queued here and
-- retried by cron/retry_failed_notifications.php with exponential backoff.
--
-- Safe to re-run.
CREATE TABLE IF NOT EXISTS `notification_retry_queue` (
`retry_id` int(11) NOT NULL AUTO_INCREMENT,
`payload` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL CHECK (json_valid(`payload`)),
`attempts` int(11) NOT NULL DEFAULT 0,
`max_attempts` int(11) NOT NULL DEFAULT 6,
`next_attempt_at` timestamp NULL DEFAULT current_timestamp(),
`last_error` varchar(500) DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
PRIMARY KEY (`retry_id`),
KEY `idx_next_attempt` (`next_attempt_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
+15 -4
View File
@@ -19,9 +19,11 @@ class ApiKeyModel
* @param int $createdBy User ID who created the key
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
* @param string $scope Access scope: 'read' or 'read_write' (default 'read_write')
* @param bool $seeAllVisibility If true, the key bypasses ticket visibility (Confidential/
* Internal included); defaults to false (public tickets only)
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'scope', 'error'
*/
public function createKey($keyName, $createdBy, $expiresInDays = null, $scope = 'read_write')
public function createKey($keyName, $createdBy, $expiresInDays = null, $scope = 'read_write', $seeAllVisibility = false)
{
// Validate the requested scope — only the two known values are allowed
if (!in_array($scope, ['read', 'read_write'], true)) {
@@ -47,11 +49,12 @@ class ApiKeyModel
}
// Insert API key into database
$seeAllVisibilityInt = $seeAllVisibility ? 1 : 0;
$stmt = $this->conn->prepare(
"INSERT INTO api_keys (key_name, key_hash, key_prefix, scope, created_by, expires_at) "
. "VALUES (?, ?, ?, ?, ?, ?)"
"INSERT INTO api_keys (key_name, key_hash, key_prefix, scope, see_all_visibility, created_by, expires_at) "
. "VALUES (?, ?, ?, ?, ?, ?, ?)"
);
$stmt->bind_param("ssssis", $keyName, $keyHash, $keyPrefix, $scope, $createdBy, $expiresAt);
$stmt->bind_param("ssssiis", $keyName, $keyHash, $keyPrefix, $scope, $seeAllVisibilityInt, $createdBy, $expiresAt);
if ($stmt->execute()) {
$keyId = $this->conn->insert_id;
@@ -63,6 +66,7 @@ class ApiKeyModel
'key_prefix' => $keyPrefix,
'key_id' => $keyId,
'scope' => $scope,
'see_all_visibility' => $seeAllVisibility,
'expires_at' => $expiresAt
];
} else {
@@ -114,6 +118,13 @@ class ApiKeyModel
$keyData['scope'] = 'read_write';
}
// Unlike scope's backward-compatible fallback above, an un-migrated or
// null see_all_visibility defaults to the RESTRICTIVE value (public
// tickets only) — this column exists specifically to lock down a
// previously-unrestricted default, so a missing value must not fall
// back to the permissive behavior it's replacing.
$keyData['see_all_visibility'] = !empty($keyData['see_all_visibility']);
// Check expiration
if ($keyData['expires_at'] !== null) {
$expiresAt = strtotime($keyData['expires_at']);
+9 -4
View File
@@ -68,14 +68,19 @@ class AttachmentModel
/**
* Add a new attachment record
*
* @param string|null $thumbnailFilename Stored filename of a generated preview
* thumbnail, or null if none was generated
* (non-image, GD unavailable, etc.) — callers
* fall back to the full-size original.
*/
public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy)
public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy, $thumbnailFilename = null)
{
$sql = "INSERT INTO ticket_attachments (ticket_id, filename, original_filename, file_size, mime_type, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?)";
$sql = "INSERT INTO ticket_attachments (ticket_id, filename, thumbnail_filename, original_filename, file_size, mime_type, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("sssisi", $ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy);
$stmt->bind_param("ssssisi", $ticketId, $filename, $thumbnailFilename, $originalFilename, $fileSize, $mimeType, $uploadedBy);
$result = $stmt->execute();
if ($result) {
+2 -1
View File
@@ -20,7 +20,8 @@ class AuditLogModel
private const VALID_ACTION_TYPES = [
'create', 'update', 'delete', 'view', 'security_event',
'login', 'logout', 'assign', 'unassign', 'comment', 'mention',
'revoke', 'attachment_upload', 'attachment_delete', 'bulk_update'
'revoke', 'attachment_upload', 'attachment_delete', 'bulk_update',
'watch', 'unwatch'
];
/** @var array Allowed entity types for filtering */
+1 -1
View File
@@ -148,7 +148,7 @@ class StatsModel
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY priority
UNION ALL
SELECT 'status' as type, status as label, COUNT(*) as count
FROM tickets t WHERE ($visSQL) GROUP BY status
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY status
UNION ALL
SELECT 'category' as type, category as label, COUNT(*) as count
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY category";
+8 -6
View File
@@ -59,10 +59,11 @@ try {
exit(1);
}
// Prepared lookup: does any attachment row reference this stored filename?
// Stored filenames are globally unique (uniqid), so filename alone is sufficient
// and safe — a match in any ticket means the file is a real attachment.
$lookup = $conn->prepare('SELECT 1 FROM ticket_attachments WHERE filename = ? LIMIT 1');
// Prepared lookup: does any attachment row reference this stored filename,
// either as the original file or as its generated preview thumbnail? Both
// are globally unique (uniqid-derived), so filename alone is sufficient and
// safe — a match in any ticket means the file is a real, referenced file.
$lookup = $conn->prepare('SELECT 1 FROM ticket_attachments WHERE filename = ? OR thumbnail_filename = ? LIMIT 1');
if ($lookup === false) {
logMessage('FATAL ERROR: could not prepare lookup statement: ' . $conn->error);
exit(1);
@@ -101,8 +102,9 @@ foreach (new DirectoryIterator($uploadRoot) as $entry) {
continue;
}
// Keep the file if any attachment row references it.
$lookup->bind_param('s', $filename);
// Keep the file if any attachment row references it (as the
// original or as its thumbnail).
$lookup->bind_param('ss', $filename, $filename);
$lookup->execute();
$hasRow = $lookup->get_result()->num_rows > 0;
+98
View File
@@ -0,0 +1,98 @@
<?php
/**
* Assigning / unassigning a ticket: access check, permission (admin, creator,
* or current assignee), audit log, optional Matrix assignment notification,
* stats-cache invalidation.
*
* Shared by the web UI (api/assign_ticket.php) and the MCP assign_ticket tool
* so both run one code path (tinker_tickets#111). Extracted verbatim from
* assign_ticket.php. Returns result arrays; failures carry 'http_status'.
*/
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__) . '/models/StatsModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
class AssignmentService
{
/**
* @param array $currentUser Authenticated user row (user_id, username, display_name, is_admin, ...)
* @param array $data ticket_id, assigned_to (user_id; null/'' to unassign)
*/
public static function assign(mysqli $conn, array $currentUser, array $data): array
{
$userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false;
$ticketIdRaw = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
$assignedTo = $data['assigned_to'] ?? null;
if (!ctype_digit($ticketIdRaw) || (int)$ticketIdRaw <= 0) {
return ['success' => false, 'error' => 'Ticket ID required', 'http_status' => 400];
}
$ticketId = $ticketIdRaw;
$ticketModel = new TicketModel($conn);
$auditLogModel = new AuditLogModel($conn);
$userModel = new UserModel($conn);
// Verify ticket exists and user can access it
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404];
}
// Authorization: only admins or the ticket creator/assignee can reassign
if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) {
return ['success' => false, 'error' => 'Permission denied', 'http_status' => 403];
}
if ($assignedTo === null || $assignedTo === '') {
// Unassign ticket
$success = $ticketModel->unassignTicket($ticketId, $userId);
if ($success) {
$auditLogModel->log($userId, 'unassign', 'ticket', $ticketId);
}
} else {
// Validate assigned_to is a valid user ID
$assignedTo = (int)$assignedTo;
$targetUser = $userModel->getUserById($assignedTo);
if (!$targetUser) {
return ['success' => false, 'error' => 'Invalid user ID', 'http_status' => 400];
}
// Assign ticket
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
if ($success) {
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) {
$changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null;
$assigneeMatrix = isset($targetUser['username'])
? SynapseHelper::resolveUsername($targetUser['username'])
: null;
NotificationHelper::sendAssignmentNotification(
$ticketId,
$ticket['title'] ?? "Ticket #{$ticketId}",
$assigneeName,
$assigneeMatrix,
$changedByDisplay,
$ticket['visibility'] ?? 'public'
);
}
}
}
if (!$success) {
return ['success' => false, 'error' => 'Failed to update ticket assignment', 'http_status' => 500];
}
(new StatsModel($conn))->invalidateCache();
return ['success' => true];
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
/**
* Adding a comment to a ticket: validation, access check, reply-parent check,
* @mention extraction (audit-logged, and notified only to mentioned users who
* can see the ticket), comment + watcher notifications.
*
* Shared by the web UI (api/add_comment.php) and the MCP add_comment tool so
* both run one code path (tinker_tickets#111). Extracted verbatim from
* add_comment.php. Returns result arrays; on validation failure the array
* carries 'http_status' for HTTP callers. Callers own sessions/CSRF/responses.
*/
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
class CommentService
{
/**
* @param array $currentUser Authenticated user row (user_id, username, display_name, groups, is_admin)
* @param array $data ticket_id, comment_text, optional markdown_enabled / parent_comment_id
*/
public static function addComment(mysqli $conn, array $currentUser, array $data): array
{
$userId = $currentUser['user_id'];
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
if (!ctype_digit($ticketId) || (int)$ticketId <= 0) {
return ['success' => false, 'error' => 'Invalid ticket ID', 'http_status' => 400];
}
// Reject empty/whitespace-only comments
$commentTextRaw = isset($data['comment_text']) ? trim((string)$data['comment_text']) : '';
if ($commentTextRaw === '') {
return ['success' => false, 'error' => 'Comment text cannot be empty', 'http_status' => 400];
}
// Persist the trimmed text (not the raw client value) — matches update_comment.php
// and keeps stored comment_text free of leading whitespace that could shift a
// markdown-enabled comment's first line out of column 0 on reload.
$data['comment_text'] = $commentTextRaw;
// Never trust a client-supplied display name — always attribute the comment to
// the authenticated user.
$data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
// Verify user can access the ticket before allowing a comment
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404];
}
if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
return ['success' => false, 'error' => 'Access denied', 'http_status' => 403];
}
// Initialize models
$commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn);
// If replying, the parent comment must belong to this same (accessible) ticket.
if (isset($data['parent_comment_id']) && $data['parent_comment_id'] !== null && $data['parent_comment_id'] !== '') {
$parentComment = $commentModel->getCommentById((int)$data['parent_comment_id']);
if (!$parentComment || (string)$parentComment['ticket_id'] !== (string)$ticketId) {
return ['success' => false, 'error' => 'Invalid parent comment', 'http_status' => 400];
}
}
// Extract @mentions from comment text
$mentions = $commentModel->extractMentions($data['comment_text'] ?? '');
$mentionedUsers = [];
if (!empty($mentions)) {
$mentionedUsers = $commentModel->getMentionedUsers($mentions);
}
// Add comment with user tracking
$result = $commentModel->addComment($ticketId, $data, $userId);
// Log comment creation to audit log
if ($result['success'] && isset($result['comment_id'])) {
$auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
// Log mentions to audit log
foreach ($mentionedUsers as $mentionedUser) {
$auditLog->log(
$userId,
'mention',
'user',
(string)$mentionedUser['user_id'],
[
'ticket_id' => $ticketId,
'comment_id' => $result['comment_id'],
'mentioned_username' => $mentionedUser['username']
]
);
}
// Matrix notifications
$authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$commentText = $data['comment_text'] ?? '';
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
$ticketVisibility = $ticket['visibility'] ?? 'public';
// @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);
}
}
// General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
NotificationHelper::sendCommentNotification(
$ticketId,
$ticketTitle,
$commentText,
$authorDisplay,
$ticketVisibility !== 'public',
$ticketVisibility
);
}
// Notify watchers of the new comment
NotificationHelper::notifyWatchers(
$conn,
$ticketId,
$ticketTitle,
'comment_added',
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
(int)$userId,
$ticketVisibility
);
// Add mentioned users to result for frontend
$result['mentions'] = array_map(function ($u) {
return $u['username'];
}, $mentionedUsers);
}
// Add user info to result for frontend avatar rendering
if ($result['success']) {
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
$result['user_id'] = $userId;
}
return $result;
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
/**
* Creating a ticket as a user: required-field and required-custom-field
* validation, TicketModel::createTicket (which enforces visibility rules),
* audit log, stats-cache invalidation, custom field values, optional
* "duplicates" link, and the new-ticket Matrix notification.
*
* Shared by the web UI (TicketController::create) and the MCP create_ticket
* tool so both run one code path (tinker_tickets#111). Extracted from
* TicketController::create with the same checks, order and error messages.
* Callers own request parsing, CSRF, and rendering.
*/
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
require_once dirname(__DIR__) . '/models/StatsModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
class TicketCreationService
{
/**
* @param array $currentUser Authenticated user row
* @param array $input title, description, priority, category, type, status,
* visibility, visibility_groups (string or list), assigned_to,
* custom_fields (field_id => value), link_duplicate_of
*
* @return array ['success' => true, 'ticket_id' => ...] or ['success' => false, 'error' => ...]
*/
public static function create(mysqli $conn, array $currentUser, array $input): array
{
$userId = $currentUser['user_id'] ?? null;
// Handle visibility groups (a list from the web form's checkboxes, or a string)
$visibilityGroups = null;
if (isset($input['visibility_groups']) && is_array($input['visibility_groups'])) {
$visibilityGroups = implode(',', array_map('trim', $input['visibility_groups']));
} elseif (isset($input['visibility_groups']) && is_string($input['visibility_groups']) && trim($input['visibility_groups']) !== '') {
$visibilityGroups = trim($input['visibility_groups']);
}
// Honor the posted status, validated against the app's canonical list
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
$status = $input['status'] ?? 'Open';
if (!in_array($status, $validStatuses, true)) {
$status = 'Open';
}
$ticketData = [
'title' => trim($input['title'] ?? ''),
'description' => $input['description'] ?? '',
'priority' => $input['priority'] ?? '4',
'category' => $input['category'] ?? 'General',
'type' => $input['type'] ?? 'Issue',
'status' => $status,
'visibility' => $input['visibility'] ?? 'public',
'visibility_groups' => $visibilityGroups,
'assigned_to' => !empty($input['assigned_to']) ? $input['assigned_to'] : null
];
if ($ticketData['title'] === '') {
return ['success' => false, 'error' => 'Title is required'];
}
if (trim($ticketData['description']) === '') {
return ['success' => false, 'error' => 'Description is required'];
}
// Custom fields applicable to the submitted category — validate
// is_required server-side (the form is novalidate, and a field
// hidden by the client-side category toggle must not silently
// bypass a requirement that applies to the category actually
// submitted).
$customFieldModel = new CustomFieldModel($conn);
$allCustomFieldDefs = $customFieldModel->getAllDefinitions(null, true);
$submittedCustomFields = is_array($input['custom_fields'] ?? null) ? $input['custom_fields'] : [];
$applicableFieldDefs = array_filter(
$allCustomFieldDefs,
fn($def) => $def['category'] === null || $def['category'] === $ticketData['category']
);
$customFieldsToSave = [];
foreach ($applicableFieldDefs as $def) {
$fieldId = (int)$def['field_id'];
$raw = $submittedCustomFields[$fieldId] ?? null;
$normalized = $def['field_type'] === 'checkbox'
? (!empty($raw) ? '1' : '0')
: (is_scalar($raw) ? trim((string)$raw) : '');
if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
return ['success' => false, 'error' => $def['field_label'] . ' is required'];
}
if ($normalized !== '') {
$customFieldsToSave[$fieldId] = $normalized;
}
}
// Create ticket with user tracking
$result = (new TicketModel($conn))->createTicket($ticketData, $userId);
if (!$result['success']) {
return ['success' => false, 'error' => $result['error']];
}
// Log ticket creation to audit log
if ($userId) {
(new AuditLogModel($conn))->logTicketCreate($userId, $result['ticket_id'], $ticketData);
}
// Ticket counts changed — invalidate the cached dashboard stats
(new StatsModel($conn))->invalidateCache();
// Persist custom field values for the fields applicable to this ticket's category
if (!empty($customFieldsToSave)) {
$customFieldModel->setValues($result['ticket_id'], $customFieldsToSave);
}
// Auto-link as duplicate if requested
$linkDupOfRaw = trim((string)($input['link_duplicate_of'] ?? ''));
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
$depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by)
VALUES (?, ?, 'duplicates', ?)";
$depStmt = $conn->prepare($depSql);
$depStmt->bind_param("ssi", $result['ticket_id'], $linkDupOfRaw, $userId);
$depStmt->execute();
$depStmt->close();
}
// Send Matrix notification for new ticket
NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual');
return ['success' => true, 'ticket_id' => $result['ticket_id']];
}
}
+5 -2
View File
@@ -280,8 +280,11 @@ include __DIR__ . '/layout_header.php';
// default: with no `status` param the controller falls back to the viewer's
// default_status_filters preference, which can be anything, so the resulting
// list would not necessarily match what the chart counted. StatsModel builds
// by_priority and by_category with `status != 'Closed'`, while by_status spans
// every status — so only the priority and category charts pin the open set.
// by_priority, by_status, and by_category all with `status != 'Closed'` —
// closed tickets accumulate indefinitely and would otherwise dominate every
// breakdown over time — so chartPriority/chartCategory pin the open set
// explicitly, while chartStatus's clicked label is itself already one of
// the non-Closed statuses.
function openStatuses() {
var all = window.TICKET_STATUSES || ['Open', 'Pending', 'In Progress', 'Closed'];
return all.filter(function(s) { return s !== 'Closed'; }).join(',');
+6
View File
@@ -32,6 +32,8 @@ function getEventIcon(string $actionType): string
'status_change' => '[!]',
'attachment' => '[^]',
'delete' => '[x]',
'watch' => '[o]',
'unwatch' => '[o]',
default => '[*]',
};
}
@@ -53,6 +55,10 @@ function formatAction(array $event): string
return 'uploaded a file';
case 'delete':
return 'deleted a comment';
case 'watch':
return 'started watching this ticket';
case 'unwatch':
return 'stopped watching this ticket';
case 'assign':
if (is_array($det) && isset($det['assigned_to']['to'])) {
$to = $det['assigned_to']['to'] ?: 'Unassigned';
+27 -5
View File
@@ -45,10 +45,18 @@ include __DIR__ . '/../../views/layout_header.php';
<option value="read">read</option>
</select>
</div>
<div class="lt-form-group" style="flex:1;margin:0">
<label class="lt-label" style="display:flex;align-items:center;gap:0.4rem;cursor:pointer">
<input type="checkbox" id="keySeeAllVisibility">
See all visibility
</label>
</div>
<button type="submit" class="lt-btn lt-btn-primary" style="margin-bottom:0">GENERATE KEY</button>
</form>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">
Scope: <strong>read</strong> = GET only; <strong>read_write</strong> = create/comment/close.
By default a key only sees <strong>public</strong>-visibility tickets — check
<strong>See all visibility</strong> only if this key genuinely needs Confidential/Internal tickets too.
</p>
<!-- New key display (hidden by default) -->
@@ -74,6 +82,7 @@ include __DIR__ . '/../../views/layout_header.php';
<th scope="col">Name</th>
<th scope="col">Key Prefix</th>
<th scope="col">Scope</th>
<th scope="col">Visibility</th>
<th scope="col">Created By</th>
<th scope="col">Created</th>
<th scope="col">Expires</th>
@@ -86,7 +95,7 @@ include __DIR__ . '/../../views/layout_header.php';
<?php
$apiKeysList = $apiKeys['keys'] ?? [];
if (empty($apiKeysList)) : ?>
<tr><td colspan="9" class="lt-empty">No API keys found. Generate one above.</td></tr>
<tr><td colspan="10" class="lt-empty">No API keys found. Generate one above.</td></tr>
<?php else :
foreach ($apiKeysList as $key) : ?>
<?php
@@ -103,6 +112,13 @@ include __DIR__ . '/../../views/layout_header.php';
<span class="lt-status lt-status-open"><?= htmlspecialchars($scope) ?></span>
<?php endif ?>
</td>
<td data-label="Visibility">
<?php if (!empty($key['see_all_visibility'])) : ?>
<span class="lt-status lt-status-open" title="Bypasses ticket visibility — sees Confidential/Internal tickets too">all</span>
<?php else : ?>
<span class="lt-status lt-status-closed" title="Only sees public-visibility tickets">public only</span>
<?php endif ?>
</td>
<td data-label="Created By" class="lt-text-xs"><?= htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown') ?></td>
<td data-label="Created" class="lt-text-xs lt-text-muted"><?= date('Y-m-d H:i', strtotime($key['created_at'])) ?></td>
<td data-label="Expires" class="lt-text-xs <?= $expired ? 'lt-text-danger' : 'lt-text-cyan' ?>">
@@ -239,11 +255,17 @@ document.addEventListener('click', function (e) {
document.getElementById('generateKeyForm').addEventListener('submit', function (e) {
e.preventDefault();
var keyName = document.getElementById('keyName').value.trim();
var expiresIn = document.getElementById('expiresIn').value;
var keyScope = document.getElementById('keyScope').value;
var keyName = document.getElementById('keyName').value.trim();
var expiresIn = document.getElementById('expiresIn').value;
var keyScope = document.getElementById('keyScope').value;
var seeAllVisibility = document.getElementById('keySeeAllVisibility').checked;
if (!keyName) { lt.toast.error('Please enter a key name'); return; }
lt.api.post('/api/generate_api_key.php', { key_name: keyName, expires_in_days: expiresIn || null, scope: keyScope })
lt.api.post('/api/generate_api_key.php', {
key_name: keyName,
expires_in_days: expiresIn || null,
scope: keyScope,
see_all_visibility: seeAllVisibility
})
.then(function (data) {
if (data.success) {
document.getElementById('newKeyValue').value = data.api_key;