Compare commits

..
Author SHA1 Message Date
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
35 changed files with 4357 additions and 629 deletions
+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"/>
+6
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) |
@@ -522,6 +525,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);
+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();
+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 = ?"
+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
+8
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
+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
+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 ?? ''));
}
/**
@@ -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']);
+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 */
+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']];
}
}
+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;