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; } }