- Art column: only lit/media-delete and lit/divi-delete say "löscht", lit/post-trash "Papierkorb", other destructive tools "überschreibt". - ConnectionUsage records only responses with status < 400 (mcp-oauth answers auth failures with WP_REST_Response 401, not WP_Error). - Log result column: translated "unbekannt" instead of the raw value. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
95 lines
3.3 KiB
PHP
95 lines
3.3 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Admin;
|
||
|
||
use LIT\MCP\Server\ServerRegistrar;
|
||
use Throwable;
|
||
use WP_REST_Request;
|
||
use WPMedia\MCP\OAuth\Auth\JWT;
|
||
use WPMedia\MCP\OAuth\Auth\SecretManager;
|
||
|
||
/**
|
||
* Records when an OAuth connection was last used, for "Zuletzt genutzt" in the tab "Zugriff".
|
||
*
|
||
* mcp-oauth authenticates with a JWT bound to an application password but never records
|
||
* its usage (WordPress only does that for Basic-Auth application passwords), so last_used
|
||
* stayed empty. After an authenticated request to our MCP route this calls WordPress' own
|
||
* WP_Application_Passwords::record_application_password_usage(), which writes at most once
|
||
* a day per password – the same data and throttle as in the user's profile.
|
||
*/
|
||
final class ConnectionUsage
|
||
{
|
||
/** @var callable(string): ?array<string, mixed> */
|
||
private $decode;
|
||
|
||
/** @var callable(int, string): mixed */
|
||
private $recordUsage;
|
||
|
||
/**
|
||
* @param (callable(string): ?array<string, mixed>)|null $decode JWT → claims, null if invalid.
|
||
* @param (callable(int, string): mixed)|null $recordUsage user id, application password uuid.
|
||
*/
|
||
public function __construct(?callable $decode = null, ?callable $recordUsage = null)
|
||
{
|
||
$this->decode = $decode ?? static function (string $token): ?array {
|
||
$claims = JWT::decode($token, SecretManager::get_secret());
|
||
|
||
return is_array($claims) ? $claims : null;
|
||
};
|
||
$this->recordUsage = $recordUsage ?? static function (int $userId, string $uuid): void {
|
||
\WP_Application_Passwords::record_application_password_usage($userId, $uuid);
|
||
};
|
||
}
|
||
|
||
public function register(): void
|
||
{
|
||
add_filter('rest_request_after_callbacks', [$this, 'record'], 10, 3);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $handler
|
||
* @return mixed The response, unchanged.
|
||
*/
|
||
public function record(mixed $response, array $handler, WP_REST_Request $request): mixed
|
||
{
|
||
$route = '/' . ServerRegistrar::NAMESPACE . '/' . ServerRegistrar::ROUTE;
|
||
if ($request->get_route() !== $route || ! self::succeeded($response)) {
|
||
return $response;
|
||
}
|
||
|
||
$header = $request->get_header('authorization');
|
||
$userId = get_current_user_id();
|
||
if (! is_string($header) || stripos($header, 'Bearer ') !== 0 || $userId <= 0) {
|
||
return $response;
|
||
}
|
||
|
||
try {
|
||
$claims = ($this->decode)(substr($header, 7));
|
||
$uuid = is_array($claims) ? (string) ($claims['app_pass_id'] ?? '') : '';
|
||
// Only the password of the user this request actually authenticated as.
|
||
if ($uuid !== '' && (int) ($claims['sub'] ?? 0) === $userId) {
|
||
($this->recordUsage)($userId, $uuid);
|
||
}
|
||
} catch (Throwable) {
|
||
// Bookkeeping only – never break the MCP response.
|
||
}
|
||
|
||
return $response;
|
||
}
|
||
|
||
/**
|
||
* Only successful answers count as use: mcp-oauth answers auth failures with a
|
||
* WP_REST_Response(401), not a WP_Error (OAuthHttpTransport::build_auth_error_response).
|
||
*/
|
||
private static function succeeded(mixed $response): bool
|
||
{
|
||
if (is_wp_error($response)) {
|
||
return false;
|
||
}
|
||
|
||
return ! $response instanceof \WP_HTTP_Response || $response->get_status() < 400;
|
||
}
|
||
}
|