chore: Rename Astroglobe -> Astrolabe
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Service;
|
||||
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IConfig;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Refreshes OAuth tokens directly with the Identity Provider.
|
||||
*
|
||||
* Works with both Nextcloud OIDC and external IdPs like Keycloak.
|
||||
* Uses OIDC discovery to find the token endpoint automatically.
|
||||
*
|
||||
* This service is only used for confidential clients (with client_secret).
|
||||
* Public clients without client_secret cannot refresh tokens.
|
||||
*/
|
||||
class IdpTokenRefresher {
|
||||
private $config;
|
||||
private $httpClient;
|
||||
private $logger;
|
||||
private $mcpServerClient;
|
||||
|
||||
public function __construct(
|
||||
IConfig $config,
|
||||
IClientService $clientService,
|
||||
LoggerInterface $logger,
|
||||
McpServerClient $mcpServerClient,
|
||||
) {
|
||||
$this->config = $config;
|
||||
$this->httpClient = $clientService->newClient();
|
||||
$this->logger = $logger;
|
||||
$this->mcpServerClient = $mcpServerClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token.
|
||||
*
|
||||
* Calls IdP's token endpoint directly (NOT MCP server).
|
||||
*
|
||||
* @param string $refreshToken The refresh token
|
||||
* @return array|null New token data or null on failure
|
||||
*/
|
||||
public function refreshAccessToken(string $refreshToken): ?array {
|
||||
// Check if confidential client secret is configured
|
||||
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
|
||||
|
||||
if (empty($clientSecret)) {
|
||||
$this->logger->warning('Cannot refresh: no client secret configured. Confidential client required for token refresh.');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get MCP server URL
|
||||
$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
|
||||
if (empty($mcpServerUrl)) {
|
||||
throw new \Exception('MCP server URL not configured');
|
||||
}
|
||||
|
||||
// Query MCP server to discover which IdP it's configured to use
|
||||
$statusResponse = $this->httpClient->get($mcpServerUrl . '/api/v1/status');
|
||||
$statusData = json_decode($statusResponse->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid status response from MCP server');
|
||||
}
|
||||
|
||||
// Determine OIDC discovery URL and token endpoint
|
||||
$useInternalNextcloud = !isset($statusData['oidc']['discovery_url']);
|
||||
|
||||
if (!$useInternalNextcloud) {
|
||||
// External IdP configured - use OIDC discovery
|
||||
$discoveryUrl = $statusData['oidc']['discovery_url'];
|
||||
|
||||
$this->logger->info('IdpTokenRefresher: Using external IdP', [
|
||||
'discovery_url' => $discoveryUrl,
|
||||
]);
|
||||
|
||||
$discoveryResponse = $this->httpClient->get($discoveryUrl);
|
||||
$discovery = json_decode($discoveryResponse->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !isset($discovery['token_endpoint'])) {
|
||||
throw new \RuntimeException('Invalid OIDC discovery response');
|
||||
}
|
||||
|
||||
$tokenEndpoint = $discovery['token_endpoint'];
|
||||
} else {
|
||||
// Nextcloud's OIDC app - use internal URL directly
|
||||
$tokenEndpoint = 'http://localhost/apps/oidc/token';
|
||||
|
||||
$this->logger->info('IdpTokenRefresher: Using Nextcloud OIDC app', [
|
||||
'token_endpoint' => $tokenEndpoint,
|
||||
]);
|
||||
}
|
||||
|
||||
// Call IdP's token endpoint with refresh_token grant
|
||||
$postData = [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $refreshToken,
|
||||
'client_id' => $this->mcpServerClient->getClientId(),
|
||||
'client_secret' => $clientSecret,
|
||||
];
|
||||
|
||||
$this->logger->info('IdpTokenRefresher: Requesting token refresh');
|
||||
|
||||
$response = $this->httpClient->post($tokenEndpoint, [
|
||||
'body' => http_build_query($postData),
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
$tokenData = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !isset($tokenData['access_token'])) {
|
||||
throw new \RuntimeException('Invalid token response from IdP');
|
||||
}
|
||||
|
||||
$this->logger->info('IdpTokenRefresher: Token refresh successful');
|
||||
|
||||
return $tokenData;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('IdpTokenRefresher: Token refresh failed', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Service;
|
||||
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IConfig;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* HTTP client for communicating with the MCP server's management API.
|
||||
*
|
||||
* This service wraps the MCP server's REST API endpoints defined in ADR-018.
|
||||
* It handles authentication via OAuth bearer tokens and provides typed methods
|
||||
* for all management operations.
|
||||
*/
|
||||
class McpServerClient {
|
||||
private $httpClient;
|
||||
private $config;
|
||||
private $logger;
|
||||
private $baseUrl;
|
||||
|
||||
public function __construct(
|
||||
IClientService $clientService,
|
||||
IConfig $config,
|
||||
LoggerInterface $logger,
|
||||
) {
|
||||
$this->httpClient = $clientService->newClient();
|
||||
$this->config = $config;
|
||||
$this->logger = $logger;
|
||||
|
||||
// Get MCP server configuration from Nextcloud config
|
||||
$this->baseUrl = $this->config->getSystemValue('mcp_server_url', 'http://localhost:8000');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server status (version, auth mode, features).
|
||||
*
|
||||
* Public endpoint - no authentication required.
|
||||
*
|
||||
* @return array{
|
||||
* version?: string,
|
||||
* auth_mode?: string,
|
||||
* vector_sync_enabled?: bool,
|
||||
* uptime_seconds?: int,
|
||||
* management_api_version?: string,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function getStatus(): array {
|
||||
try {
|
||||
$response = $this->httpClient->get($this->baseUrl . '/api/v1/status');
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to get MCP server status', [
|
||||
'error' => $e->getMessage(),
|
||||
'server_url' => $this->baseUrl,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user session details.
|
||||
*
|
||||
* Requires authentication via OAuth bearer token.
|
||||
*
|
||||
* @param string $userId The user ID to query
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{
|
||||
* session_id?: string,
|
||||
* background_access_granted?: bool,
|
||||
* background_access_details?: array,
|
||||
* idp_profile?: array,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function getUserSession(string $userId, string $token): array {
|
||||
try {
|
||||
$response = $this->httpClient->get(
|
||||
$this->baseUrl . '/api/v1/users/' . urlencode($userId) . '/session',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
]
|
||||
]
|
||||
);
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to get session for user $userId", [
|
||||
'error' => $e->getMessage(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke user's background access (delete refresh token).
|
||||
*
|
||||
* Requires authentication via OAuth bearer token.
|
||||
*
|
||||
* @param string $userId The user ID whose access to revoke
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{success?: bool, message?: string, error?: string}
|
||||
*/
|
||||
public function revokeUserAccess(string $userId, string $token): array {
|
||||
try {
|
||||
$response = $this->httpClient->post(
|
||||
$this->baseUrl . '/api/v1/users/' . urlencode($userId) . '/revoke',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
]
|
||||
]
|
||||
);
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to revoke access for user $userId", [
|
||||
'error' => $e->getMessage(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vector sync status (indexing metrics).
|
||||
*
|
||||
* Public endpoint - no authentication required.
|
||||
* Only available if VECTOR_SYNC_ENABLED=true on server.
|
||||
*
|
||||
* @return array{
|
||||
* status?: string,
|
||||
* indexed_documents?: int,
|
||||
* pending_documents?: int,
|
||||
* last_sync_time?: string,
|
||||
* documents_per_second?: float,
|
||||
* errors_24h?: int,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function getVectorSyncStatus(): array {
|
||||
try {
|
||||
$response = $this->httpClient->get($this->baseUrl . '/api/v1/vector-sync/status');
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to get vector sync status', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute semantic search for vector visualization.
|
||||
*
|
||||
* Requires OAuth bearer token for user-filtered search.
|
||||
* Only available if VECTOR_SYNC_ENABLED=true on server.
|
||||
*
|
||||
* @param string $query Search query string
|
||||
* @param string $algorithm Search algorithm: "semantic", "bm25", or "hybrid"
|
||||
* @param int $limit Number of results (max 50)
|
||||
* @param bool $includePca Whether to include PCA coordinates for 2D plot
|
||||
* @param array|null $docTypes Document types to filter (e.g., ['note', 'file'])
|
||||
* @param string|null $token OAuth bearer token for authentication
|
||||
* @return array{
|
||||
* results?: array,
|
||||
* pca_coordinates?: array,
|
||||
* algorithm_used?: string,
|
||||
* total_documents?: int,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function search(
|
||||
string $query,
|
||||
string $algorithm = 'hybrid',
|
||||
int $limit = 10,
|
||||
bool $includePca = true,
|
||||
?array $docTypes = null,
|
||||
?string $token = null,
|
||||
): array {
|
||||
try {
|
||||
$requestBody = [
|
||||
'query' => $query,
|
||||
'algorithm' => $algorithm,
|
||||
'limit' => min($limit, 50), // Enforce max limit
|
||||
'include_pca' => $includePca,
|
||||
];
|
||||
|
||||
// Add doc_types filter if specified
|
||||
if ($docTypes !== null && count($docTypes) > 0) {
|
||||
$requestBody['doc_types'] = $docTypes;
|
||||
}
|
||||
|
||||
$options = ['json' => $requestBody];
|
||||
|
||||
// Add authorization header if token provided
|
||||
if ($token !== null) {
|
||||
$options['headers'] = [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
];
|
||||
}
|
||||
|
||||
$response = $this->httpClient->post(
|
||||
$this->baseUrl . '/api/v1/vector-viz/search',
|
||||
$options
|
||||
);
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to execute search', [
|
||||
'error' => $e->getMessage(),
|
||||
'query' => $query,
|
||||
'algorithm' => $algorithm,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute semantic search for Nextcloud Unified Search.
|
||||
*
|
||||
* Simplified search method specifically for the unified search provider.
|
||||
* Uses OAuth bearer token for authentication and user-scoped filtering.
|
||||
*
|
||||
* @param string $query Search query string
|
||||
* @param string $token OAuth bearer token for authentication
|
||||
* @param int $limit Maximum number of results (default: 20)
|
||||
* @param int $offset Pagination offset (default: 0)
|
||||
* @param string $algorithm Search algorithm: hybrid, semantic, or bm25 (default: hybrid)
|
||||
* @param string $fusion Fusion method for hybrid: rrf or dbsf (default: rrf)
|
||||
* @param float $scoreThreshold Minimum score threshold 0-1 (default: 0)
|
||||
* @return array{
|
||||
* results?: array<array{
|
||||
* id?: string|int,
|
||||
* title?: string,
|
||||
* doc_type?: string,
|
||||
* excerpt?: string,
|
||||
* score?: float,
|
||||
* path?: string,
|
||||
* board_id?: int,
|
||||
* card_id?: int
|
||||
* }>,
|
||||
* total_found?: int,
|
||||
* algorithm_used?: string,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function searchForUnifiedSearch(
|
||||
string $query,
|
||||
string $token,
|
||||
int $limit = 20,
|
||||
int $offset = 0,
|
||||
string $algorithm = 'hybrid',
|
||||
string $fusion = 'rrf',
|
||||
float $scoreThreshold = 0.0,
|
||||
): array {
|
||||
try {
|
||||
$response = $this->httpClient->post(
|
||||
$this->baseUrl . '/api/v1/search',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'query' => $query,
|
||||
'algorithm' => $algorithm,
|
||||
'fusion' => $fusion,
|
||||
'score_threshold' => $scoreThreshold,
|
||||
'limit' => min($limit, 100),
|
||||
'offset' => $offset,
|
||||
'include_pca' => false,
|
||||
'include_chunks' => true,
|
||||
]
|
||||
]
|
||||
);
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Unified search failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'query' => $query,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the MCP server is reachable and API key is valid.
|
||||
*
|
||||
* @return bool True if server is reachable and healthy
|
||||
*/
|
||||
public function isServerReachable(): bool {
|
||||
$status = $this->getStatus();
|
||||
return !isset($status['error']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured MCP server internal URL (for API calls).
|
||||
*
|
||||
* @return string The internal base URL
|
||||
*/
|
||||
public function getServerUrl(): string {
|
||||
return $this->baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public MCP server URL (for display, OAuth audience).
|
||||
*
|
||||
* Falls back to internal URL if public URL not configured.
|
||||
*
|
||||
* @return string The public URL users/browsers see
|
||||
*/
|
||||
public function getPublicServerUrl(): string {
|
||||
return $this->config->getSystemValue('mcp_server_public_url', $this->baseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OAuth client ID from system config.
|
||||
*
|
||||
* The Astrolabe app has its own OAuth client (separate from MCP server's client).
|
||||
* Client ID must be configured in config.php for OAuth functionality to work.
|
||||
*
|
||||
* @return string OAuth client ID or empty string if not configured
|
||||
*/
|
||||
public function getClientId(): string {
|
||||
$clientId = $this->config->getSystemValue('astrolabe_client_id', '');
|
||||
|
||||
if (empty($clientId)) {
|
||||
$this->logger->warning('astrolabe_client_id is not configured in config.php - OAuth functionality will not work');
|
||||
return '';
|
||||
}
|
||||
|
||||
$this->logger->debug('Using client ID from system config: ' . substr($clientId, 0, 8) . '...');
|
||||
return $clientId;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all registered webhooks for a user.
|
||||
*
|
||||
* Requires OAuth bearer token for authentication.
|
||||
*
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{
|
||||
* webhooks?: array<array{
|
||||
* id?: int,
|
||||
* event?: string,
|
||||
* uri?: string,
|
||||
* event_filter?: array,
|
||||
* enabled?: bool
|
||||
* }>,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function listWebhooks(string $token): array {
|
||||
try {
|
||||
$response = $this->httpClient->get(
|
||||
$this->baseUrl . '/api/v1/webhooks',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
]
|
||||
]
|
||||
);
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to list webhooks', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new webhook registration.
|
||||
*
|
||||
* Requires OAuth bearer token for authentication.
|
||||
*
|
||||
* @param string $event Event type (e.g., "\\OCA\\Files::postCreate")
|
||||
* @param string $uri Callback URI for webhook notifications
|
||||
* @param array|null $eventFilter Optional event filter parameters
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{
|
||||
* id?: int,
|
||||
* event?: string,
|
||||
* uri?: string,
|
||||
* event_filter?: array,
|
||||
* enabled?: bool,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function createWebhook(
|
||||
string $event,
|
||||
string $uri,
|
||||
?array $eventFilter,
|
||||
string $token,
|
||||
): array {
|
||||
try {
|
||||
$requestBody = [
|
||||
'event' => $event,
|
||||
'uri' => $uri,
|
||||
];
|
||||
|
||||
if ($eventFilter !== null) {
|
||||
$requestBody['event_filter'] = $eventFilter;
|
||||
}
|
||||
|
||||
$response = $this->httpClient->post(
|
||||
$this->baseUrl . '/api/v1/webhooks',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => $requestBody
|
||||
]
|
||||
);
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to create webhook', [
|
||||
'error' => $e->getMessage(),
|
||||
'event' => $event,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a webhook registration.
|
||||
*
|
||||
* Requires OAuth bearer token for authentication.
|
||||
*
|
||||
* @param int $webhookId Webhook ID to delete
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{success?: bool, error?: string}
|
||||
*/
|
||||
public function deleteWebhook(int $webhookId, string $token): array {
|
||||
try {
|
||||
$response = $this->httpClient->delete(
|
||||
$this->baseUrl . '/api/v1/webhooks/' . $webhookId,
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
]
|
||||
]
|
||||
);
|
||||
|
||||
// Successful DELETE may return 204 No Content
|
||||
if ($response->getStatusCode() === 204) {
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to delete webhook', [
|
||||
'error' => $e->getMessage(),
|
||||
'webhook_id' => $webhookId,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of installed Nextcloud apps.
|
||||
*
|
||||
* Used to filter webhook presets based on available apps.
|
||||
* Requires OAuth bearer token for authentication.
|
||||
*
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{
|
||||
* apps?: array<string>,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function getInstalledApps(string $token): array {
|
||||
try {
|
||||
$response = $this->httpClient->get(
|
||||
$this->baseUrl . '/api/v1/apps',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
]
|
||||
]
|
||||
);
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to get installed apps', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chunk context (text, surrounding context, page image).
|
||||
*
|
||||
* Requires OAuth bearer token for authentication.
|
||||
*
|
||||
* @param string $docType Document type
|
||||
* @param string $docId Document ID
|
||||
* @param int $start Start offset
|
||||
* @param int $end End offset
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array
|
||||
*/
|
||||
public function getChunkContext(
|
||||
string $docType,
|
||||
string $docId,
|
||||
int $start,
|
||||
int $end,
|
||||
string $token,
|
||||
): array {
|
||||
try {
|
||||
$response = $this->httpClient->get(
|
||||
$this->baseUrl . '/api/v1/chunk-context',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
],
|
||||
'query' => [
|
||||
'doc_type' => $docType,
|
||||
'doc_id' => $docId,
|
||||
'start' => $start,
|
||||
'end' => $end,
|
||||
'context' => 500
|
||||
]
|
||||
]
|
||||
);
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to get chunk context', [
|
||||
'error' => $e->getMessage(),
|
||||
'doc_type' => $docType,
|
||||
'doc_id' => $docId,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Service;
|
||||
|
||||
use OCP\IConfig;
|
||||
use OCP\Security\ICrypto;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Storage service for per-user MCP OAuth tokens.
|
||||
*
|
||||
* Stores encrypted access and refresh tokens in user preferences.
|
||||
* Handles token expiration checking and refresh logic.
|
||||
*/
|
||||
class McpTokenStorage {
|
||||
private $config;
|
||||
private $crypto;
|
||||
private $logger;
|
||||
|
||||
public function __construct(
|
||||
IConfig $config,
|
||||
ICrypto $crypto,
|
||||
LoggerInterface $logger,
|
||||
) {
|
||||
$this->config = $config;
|
||||
$this->crypto = $crypto;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store MCP OAuth tokens for a user.
|
||||
*
|
||||
* Tokens are encrypted before storage to protect user credentials.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @param string $accessToken OAuth access token
|
||||
* @param string $refreshToken OAuth refresh token
|
||||
* @param int $expiresAt Unix timestamp when token expires
|
||||
*/
|
||||
public function storeUserToken(
|
||||
string $userId,
|
||||
string $accessToken,
|
||||
string $refreshToken,
|
||||
int $expiresAt,
|
||||
): void {
|
||||
try {
|
||||
$tokenData = [
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $refreshToken,
|
||||
'expires_at' => $expiresAt,
|
||||
];
|
||||
|
||||
// Encrypt token data before storage
|
||||
$encrypted = $this->crypto->encrypt(json_encode($tokenData));
|
||||
|
||||
// Store in user preferences
|
||||
$this->config->setUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'oauth_tokens',
|
||||
$encrypted
|
||||
);
|
||||
|
||||
$this->logger->info("Stored MCP OAuth tokens for user: $userId");
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to store MCP tokens for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP OAuth tokens for a user.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @return array|null Token data array with keys: access_token, refresh_token, expires_at
|
||||
*/
|
||||
public function getUserToken(string $userId): ?array {
|
||||
try {
|
||||
$encrypted = $this->config->getUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'oauth_tokens',
|
||||
''
|
||||
);
|
||||
|
||||
if (empty($encrypted)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decrypt and parse token data
|
||||
$decrypted = $this->crypto->decrypt($encrypted);
|
||||
$tokenData = json_decode($decrypted, true);
|
||||
|
||||
if (!$tokenData || !isset($tokenData['access_token'])) {
|
||||
$this->logger->warning("Invalid token data for user: $userId");
|
||||
return null;
|
||||
}
|
||||
|
||||
return $tokenData;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to retrieve MCP tokens for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token is expired or about to expire.
|
||||
*
|
||||
* Uses a 60-second buffer to refresh tokens before they actually expire.
|
||||
*
|
||||
* @param array $token Token data array
|
||||
* @return bool True if expired or about to expire
|
||||
*/
|
||||
public function isExpired(array $token): bool {
|
||||
if (!isset($token['expires_at'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Expire 60 seconds early to avoid race conditions
|
||||
return time() >= ($token['expires_at'] - 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete stored tokens for a user.
|
||||
*
|
||||
* Used when user disconnects or revokes access.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
*/
|
||||
public function deleteUserToken(string $userId): void {
|
||||
try {
|
||||
$this->config->deleteUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'oauth_tokens'
|
||||
);
|
||||
|
||||
$this->logger->info("Deleted MCP OAuth tokens for user: $userId");
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to delete MCP tokens for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the access token for a user, handling expiration and refresh.
|
||||
*
|
||||
* This is a convenience method that combines token retrieval,
|
||||
* expiration checking, and automatic refresh if needed.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @param callable|null $refreshCallback Callback to refresh token if expired
|
||||
* Should accept (refreshToken) and return new token data
|
||||
* @return string|null Access token, or null if not available
|
||||
*/
|
||||
public function getAccessToken(string $userId, ?callable $refreshCallback = null): ?string {
|
||||
$token = $this->getUserToken($userId);
|
||||
|
||||
if (!$token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if ($this->isExpired($token)) {
|
||||
// Try to refresh if callback provided
|
||||
if ($refreshCallback && isset($token['refresh_token'])) {
|
||||
try {
|
||||
$newTokenData = $refreshCallback($token['refresh_token']);
|
||||
|
||||
if ($newTokenData && isset($newTokenData['access_token'])) {
|
||||
// Store refreshed token
|
||||
// Use new refresh token if provided (rotation), otherwise keep old one
|
||||
$this->storeUserToken(
|
||||
$userId,
|
||||
$newTokenData['access_token'],
|
||||
$newTokenData['refresh_token'] ?? $token['refresh_token'],
|
||||
time() + ($newTokenData['expires_in'] ?? 3600)
|
||||
);
|
||||
|
||||
return $newTokenData['access_token'];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to refresh token for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
// Fall through to return null
|
||||
}
|
||||
}
|
||||
|
||||
// Token expired and no refresh available
|
||||
$this->logger->info("Token expired for user $userId, no refresh available");
|
||||
return null;
|
||||
}
|
||||
|
||||
return $token['access_token'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Service;
|
||||
|
||||
/**
|
||||
* Webhook preset configurations for common sync scenarios.
|
||||
*
|
||||
* Defines pre-configured webhook bundles that simplify webhook setup
|
||||
* for common use cases like Notes sync, Calendar sync, etc.
|
||||
*/
|
||||
class WebhookPresets {
|
||||
// File/Notes webhook events
|
||||
public const FILE_EVENT_CREATED = 'OCP\\Files\\Events\\Node\\NodeCreatedEvent';
|
||||
public const FILE_EVENT_WRITTEN = 'OCP\\Files\\Events\\Node\\NodeWrittenEvent';
|
||||
// Use BeforeNodeDeletedEvent instead of NodeDeletedEvent to get node.id
|
||||
// See: https://github.com/nextcloud/server/issues/56371
|
||||
public const FILE_EVENT_DELETED = 'OCP\\Files\\Events\\Node\\BeforeNodeDeletedEvent';
|
||||
|
||||
// Calendar webhook events
|
||||
public const CALENDAR_EVENT_CREATED = 'OCP\\Calendar\\Events\\CalendarObjectCreatedEvent';
|
||||
public const CALENDAR_EVENT_UPDATED = 'OCP\\Calendar\\Events\\CalendarObjectUpdatedEvent';
|
||||
public const CALENDAR_EVENT_DELETED = 'OCP\\Calendar\\Events\\CalendarObjectDeletedEvent';
|
||||
|
||||
// Tables webhook events (Nextcloud 30+)
|
||||
public const TABLES_EVENT_ROW_ADDED = 'OCA\\Tables\\Event\\RowAddedEvent';
|
||||
public const TABLES_EVENT_ROW_UPDATED = 'OCA\\Tables\\Event\\RowUpdatedEvent';
|
||||
public const TABLES_EVENT_ROW_DELETED = 'OCA\\Tables\\Event\\RowDeletedEvent';
|
||||
|
||||
// Forms webhook events (Nextcloud 30+)
|
||||
public const FORMS_EVENT_FORM_SUBMITTED = 'OCA\\Forms\\Events\\FormSubmittedEvent';
|
||||
|
||||
// NOTE: Deck and Contacts do NOT support webhooks
|
||||
// Their event classes do not implement IWebhookCompatibleEvent interface.
|
||||
// Alternative sync strategies:
|
||||
// - Deck: Use polling with ETag-based change detection
|
||||
// - Contacts: Use CardDAV sync-token mechanism for efficient syncing
|
||||
|
||||
/**
|
||||
* Get all available webhook presets.
|
||||
*
|
||||
* @return array<string, array{
|
||||
* name: string,
|
||||
* description: string,
|
||||
* app: string,
|
||||
* events: array<array{event: string, filter: array}>
|
||||
* }>
|
||||
*/
|
||||
public static function getPresets(): array {
|
||||
return [
|
||||
'notes_sync' => [
|
||||
'name' => 'Notes Sync',
|
||||
'description' => 'Real-time synchronization for Notes app (create, update, delete)',
|
||||
'app' => 'notes',
|
||||
'events' => [
|
||||
[
|
||||
'event' => self::FILE_EVENT_CREATED,
|
||||
'filter' => ['event.node.path' => '/^\\/.*\\/files\\/Notes\\//'],
|
||||
],
|
||||
[
|
||||
'event' => self::FILE_EVENT_WRITTEN,
|
||||
'filter' => ['event.node.path' => '/^\\/.*\\/files\\/Notes\\//'],
|
||||
],
|
||||
[
|
||||
'event' => self::FILE_EVENT_DELETED,
|
||||
'filter' => ['event.node.path' => '/^\\/.*\\/files\\/Notes\\//'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'calendar_sync' => [
|
||||
'name' => 'Calendar Sync',
|
||||
'description' => 'Real-time synchronization for Calendar events (create, update, delete)',
|
||||
'app' => 'calendar',
|
||||
'events' => [
|
||||
[
|
||||
'event' => self::CALENDAR_EVENT_CREATED,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::CALENDAR_EVENT_UPDATED,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::CALENDAR_EVENT_DELETED,
|
||||
'filter' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
'tables_sync' => [
|
||||
'name' => 'Tables Sync',
|
||||
'description' => 'Real-time synchronization for Tables rows (add, update, delete)',
|
||||
'app' => 'tables',
|
||||
'events' => [
|
||||
[
|
||||
'event' => self::TABLES_EVENT_ROW_ADDED,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::TABLES_EVENT_ROW_UPDATED,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::TABLES_EVENT_ROW_DELETED,
|
||||
'filter' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
'forms_sync' => [
|
||||
'name' => 'Forms Sync',
|
||||
'description' => 'Real-time synchronization for Forms submissions',
|
||||
'app' => 'forms',
|
||||
'events' => [
|
||||
[
|
||||
'event' => self::FORMS_EVENT_FORM_SUBMITTED,
|
||||
'filter' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
'files_sync' => [
|
||||
'name' => 'All Files Sync',
|
||||
'description' => 'Real-time synchronization for all file operations (create, update, delete)',
|
||||
'app' => 'files',
|
||||
'events' => [
|
||||
[
|
||||
'event' => self::FILE_EVENT_CREATED,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::FILE_EVENT_WRITTEN,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::FILE_EVENT_DELETED,
|
||||
'filter' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a webhook preset by ID.
|
||||
*
|
||||
* @param string $presetId Preset identifier (e.g., "notes_sync", "calendar_sync")
|
||||
* @return array|null Preset configuration or null if not found
|
||||
*/
|
||||
public static function getPreset(string $presetId): ?array {
|
||||
$presets = self::getPresets();
|
||||
return $presets[$presetId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of event class names for a preset.
|
||||
*
|
||||
* @param string $presetId Preset identifier
|
||||
* @return array<string> List of fully qualified event class names
|
||||
*/
|
||||
public static function getPresetEvents(string $presetId): array {
|
||||
$preset = self::getPreset($presetId);
|
||||
if ($preset === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(
|
||||
fn ($eventConfig) => $eventConfig['event'],
|
||||
$preset['events']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter webhook presets to only show those for installed apps.
|
||||
*
|
||||
* @param array<string> $installedApps List of installed app names
|
||||
* @return array<string, array> Filtered presets
|
||||
*/
|
||||
public static function filterPresetsByInstalledApps(array $installedApps): array {
|
||||
$filtered = [];
|
||||
foreach (self::getPresets() as $presetId => $preset) {
|
||||
$appName = $preset['app'];
|
||||
// "files" is always available (core functionality)
|
||||
if ($appName === 'files' || in_array($appName, $installedApps)) {
|
||||
$filtered[$presetId] = $preset;
|
||||
}
|
||||
}
|
||||
return $filtered;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user