Security / PHP Security (semgrep) (push) Successful in 1m45s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
Lint / PHP (phpcs PSR-12) (push) Successful in 21s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 26s
- SynapseHelper: memoize username->Matrix-ID lookups per-request (incl. negative
results) and add an overall time budget to resolveUsernames() plus a 2s connect
timeout, so notifying N watchers with a slow/unreachable Synapse can't stall the
request for N x 5s. (Chosen over async/queue per maintainer.)
- DependencyModel: fix cycle detection treating 'blocks' and 'blocked_by' as the
same edge direction. They are inverse relationships (single row each, no mirror
row), so the traversal now walks a unified precedence graph (blocks: ticket->
depends_on; blocked_by: depends_on->ticket) and wouldCreateCycle normalizes the
new edge's direction. Prevents both false-positive and missed cycles.
- CacheHelper: anchor prefix-delete to exact key boundaries (bare prefix or
prefix + '_' + md5) so delete('workflow') can't wipe a 'workflow_rules' cache.
- RateLimitMiddleware: hold an exclusive flock across the per-IP counter's
read-modify-write so concurrent requests can't both read N and write N+1
(undercounting past the limit). Fails open if the file can't be locked.
- dashboard.js: kanban status update now uses lt.api.post (per no-raw-fetch
convention) and reverts the card AND the optimistic column counts on failure
(the old raw-fetch catch left the card moved without reverting).
- BulkOperationsModel: document that bulk_status/bulk_close intentionally bypass
workflow transition validation (admin override, by design).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
120 lines
4.7 KiB
PHP
120 lines
4.7 KiB
PHP
<?php
|
||
|
||
/**
|
||
* SynapseHelper
|
||
*
|
||
* Resolves local (SSO) usernames → Matrix user IDs by querying the
|
||
* Synapse Admin REST API directly. Results are memoized per-request (not
|
||
* across requests, so they don't go stale between requests), and a batch
|
||
* resolve has an overall time budget to bound request latency.
|
||
*
|
||
* Required config (.env) keys:
|
||
* MATRIX_DOMAIN e.g. matrix.lotusguild.org
|
||
* SYNAPSE_ADMIN_URL e.g. http://10.10.10.29:8008 (internal client-API URL)
|
||
* SYNAPSE_ADMIN_TOKEN a Synapse admin access token
|
||
*/
|
||
class SynapseHelper
|
||
{
|
||
/** Per-request memo of username => Matrix ID|null, so repeat watchers are free. */
|
||
private static array $cache = [];
|
||
|
||
/** Total wall-clock budget (seconds) for a single resolveUsernames() batch. */
|
||
private const RESOLVE_BUDGET_SECONDS = 5;
|
||
|
||
/**
|
||
* Resolve a local SSO username to its Matrix user ID.
|
||
*
|
||
* Uses the Synapse Admin API v2 endpoint:
|
||
* GET /_synapse/admin/v2/users/@{username}:{domain}
|
||
*
|
||
* If the account exists in Synapse the method returns the Matrix ID string.
|
||
* If the account does not exist, or if Synapse is unreachable / not configured,
|
||
* it returns null silently (notifications are best-effort).
|
||
*
|
||
* @param string $username Local username (e.g. "jared")
|
||
* @return string|null Matrix user ID (e.g. "@jared:matrix.lotusguild.org") or null
|
||
*/
|
||
public static function resolveUsername(string $username): ?string
|
||
{
|
||
// Serve from the per-request cache when we've already looked this up.
|
||
if (array_key_exists($username, self::$cache)) {
|
||
return self::$cache[$username];
|
||
}
|
||
|
||
$baseUrl = $GLOBALS['config']['SYNAPSE_ADMIN_URL'] ?? null;
|
||
$token = $GLOBALS['config']['SYNAPSE_ADMIN_TOKEN'] ?? null;
|
||
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
||
|
||
if (!$baseUrl || !$token || !$domain) {
|
||
return null;
|
||
}
|
||
|
||
// Build the Matrix user ID and percent-encode it once for the URL path.
|
||
// rawurlencode($username) here would double-encode any special chars when
|
||
// the full $matrixId string is encoded again below.
|
||
$matrixId = '@' . $username . ':' . $domain;
|
||
$url = rtrim($baseUrl, '/') . '/_synapse/admin/v2/users/' . rawurlencode($matrixId);
|
||
|
||
$ch = curl_init($url);
|
||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||
'Authorization: Bearer ' . $token,
|
||
'Accept: application/json',
|
||
]);
|
||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 2); // fail fast when Synapse is unreachable
|
||
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
||
|
||
$body = curl_exec($ch);
|
||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
$curlError = curl_error($ch);
|
||
curl_close($ch);
|
||
|
||
$resolved = null;
|
||
if ($curlError) {
|
||
error_log("SynapseHelper: cURL error resolving '{$username}': {$curlError}");
|
||
} elseif ($httpCode === 200) {
|
||
$data = json_decode($body, true);
|
||
// Confirm the response contains the name we expect
|
||
if (!empty($data['name'])) {
|
||
$resolved = $data['name']; // e.g. "@jared:matrix.lotusguild.org"
|
||
}
|
||
} elseif ($httpCode !== 404) {
|
||
// 404 = user not found in Synapse; other codes = error
|
||
error_log("SynapseHelper: unexpected HTTP {$httpCode} resolving '{$username}'");
|
||
}
|
||
|
||
// Memoize for the rest of this request (including negative results, so a
|
||
// missing/unreachable user isn't retried within the same request).
|
||
self::$cache[$username] = $resolved;
|
||
return $resolved;
|
||
}
|
||
|
||
/**
|
||
* Resolve multiple usernames to Matrix IDs.
|
||
* Returns only those that were successfully confirmed in Synapse.
|
||
*
|
||
* @param string[] $usernames
|
||
* @return string[] Matrix user IDs
|
||
*/
|
||
public static function resolveUsernames(array $usernames): array
|
||
{
|
||
$ids = [];
|
||
$deadline = microtime(true) + self::RESOLVE_BUDGET_SECONDS;
|
||
foreach ($usernames as $username) {
|
||
// Cached lookups are free and always allowed; for uncached ones, stop
|
||
// making live calls once the batch budget is spent so a slow/unreachable
|
||
// Synapse can't stall the request for N × per-call timeout.
|
||
$cached = array_key_exists($username, self::$cache);
|
||
if (!$cached && microtime(true) >= $deadline) {
|
||
error_log('SynapseHelper: resolve budget exhausted; skipping remaining lookups');
|
||
break;
|
||
}
|
||
$id = self::resolveUsername($username);
|
||
if ($id !== null) {
|
||
$ids[] = $id;
|
||
}
|
||
}
|
||
return $ids;
|
||
}
|
||
}
|