chore: Rename Astroglobe -> Astrolabe

This commit is contained in:
Chris Coutinho
2025-12-18 00:02:08 +01:00
parent 24898439cb
commit d235dfa023
80 changed files with 256 additions and 362 deletions
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace OCA\Astrolabe\AppInfo;
use OCA\Astrolabe\Search\SemanticSearchProvider;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
class Application extends App implements IBootstrap {
public const APP_ID = 'astrolabe';
/** @psalm-suppress PossiblyUnusedMethod */
public function __construct() {
parent::__construct(self::APP_ID);
}
public function register(IRegistrationContext $context): void {
// Register unified search provider for semantic search
$context->registerSearchProvider(SemanticSearchProvider::class);
}
public function boot(IBootContext $context): void {
}
}
+721
View File
@@ -0,0 +1,721 @@
<?php
declare(strict_types=1);
namespace OCA\Astrolabe\Controller;
use OCA\Astrolabe\Service\IdpTokenRefresher;
use OCA\Astrolabe\Service\McpServerClient;
use OCA\Astrolabe\Service\McpTokenStorage;
use OCA\Astrolabe\Service\WebhookPresets;
use OCA\Astrolabe\Settings\Admin as AdminSettings;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\IConfig;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
/**
* API controller for MCP Server UI.
*
* Handles form submissions and AJAX requests from settings panels.
*/
class ApiController extends Controller {
private $client;
private $userSession;
private $urlGenerator;
private $logger;
private $tokenStorage;
private $config;
private $tokenRefresher;
public function __construct(
string $appName,
IRequest $request,
McpServerClient $client,
IUserSession $userSession,
IURLGenerator $urlGenerator,
LoggerInterface $logger,
McpTokenStorage $tokenStorage,
IConfig $config,
IdpTokenRefresher $tokenRefresher,
) {
parent::__construct($appName, $request);
$this->client = $client;
$this->userSession = $userSession;
$this->urlGenerator = $urlGenerator;
$this->logger = $logger;
$this->tokenStorage = $tokenStorage;
$this->config = $config;
$this->tokenRefresher = $tokenRefresher;
}
/**
* Revoke user's background access (delete refresh token).
*
* Called from personal settings form POST.
* Redirects back to personal settings after completion.
*
* @return RedirectResponse
*/
#[NoAdminRequired]
public function revokeAccess(): RedirectResponse {
$user = $this->userSession->getUser();
if (!$user) {
// Should not happen (NoAdminRequired ensures user is logged in)
$this->logger->error('Revoke access called without authenticated user');
return new RedirectResponse(
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
);
}
$userId = $user->getUID();
// Get user's OAuth token
$token = $this->tokenStorage->getUserToken($userId);
if (!$token) {
$this->logger->error("Cannot revoke access: No token found for user $userId");
return new RedirectResponse(
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
);
}
$accessToken = $token['access_token'];
// Call MCP server API to revoke access
$result = $this->client->revokeUserAccess($userId, $accessToken);
if (isset($result['error'])) {
$this->logger->error("Failed to revoke access for user $userId", [
'error' => $result['error']
]);
// TODO: Add flash message/notification for user feedback
} else {
$this->logger->info("Successfully revoked background access for user $userId");
// TODO: Add success flash message/notification
}
// Redirect back to personal settings
return new RedirectResponse(
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
);
}
/**
* Execute semantic search via MCP server.
*
* AJAX endpoint for vector search UI in app page.
* Uses user's OAuth token for authentication.
*
* @param string $query Search query
* @param string $algorithm Search algorithm (semantic, bm25, hybrid)
* @param int $limit Number of results (max 50)
* @param string $doc_types Comma-separated document types (e.g., "note,file")
* @param string $include_pca Whether to include PCA coordinates for visualization
* @return JSONResponse
*/
#[NoAdminRequired]
public function search(
string $query = '',
string $algorithm = 'hybrid',
int $limit = 10,
string $doc_types = '',
string $include_pca = 'true',
): JSONResponse {
if (empty($query)) {
return new JSONResponse([
'success' => false,
'error' => 'Missing required parameter: query'
], Http::STATUS_BAD_REQUEST);
}
// Get current user
$user = $this->userSession->getUser();
if (!$user) {
return new JSONResponse([
'success' => false,
'error' => 'User not authenticated'
], Http::STATUS_UNAUTHORIZED);
}
$userId = $user->getUID();
// Create refresh callback that calls IdP directly
$refreshCallback = function (string $refreshToken) {
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
if (!$newTokenData) {
return null;
}
return [
'access_token' => $newTokenData['access_token'],
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
'expires_in' => $newTokenData['expires_in'] ?? 3600,
];
};
// Get user's OAuth token for MCP server with automatic refresh
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
if (!$accessToken) {
return new JSONResponse([
'success' => false,
'error' => 'MCP server authorization required. Please authorize the app first.'
], Http::STATUS_UNAUTHORIZED);
}
// Validate algorithm
$validAlgorithms = ['semantic', 'bm25', 'hybrid'];
if (!in_array($algorithm, $validAlgorithms)) {
$algorithm = 'hybrid';
}
// Enforce limit bounds
$limit = max(1, min($limit, 50));
// Parse doc_types filter
$docTypesArray = null;
if (!empty($doc_types)) {
$validDocTypes = ['note', 'file', 'deck_card', 'calendar', 'contact', 'news_item'];
$docTypesArray = array_filter(
explode(',', $doc_types),
fn ($t) => in_array(trim($t), $validDocTypes)
);
$docTypesArray = array_map('trim', $docTypesArray);
if (empty($docTypesArray)) {
$docTypesArray = null;
}
}
// Parse include_pca (string "true"/"false" from query params)
$includePcaBool = in_array(strtolower($include_pca), ['true', '1', 'yes'], true);
// Execute search via MCP server with OAuth token
$result = $this->client->search($query, $algorithm, $limit, $includePcaBool, $docTypesArray, $accessToken);
if (isset($result['error'])) {
return new JSONResponse([
'success' => false,
'error' => $result['error']
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
$response = [
'success' => true,
'results' => $result['results'] ?? [],
'algorithm_used' => $result['algorithm_used'] ?? $algorithm,
'total_documents' => $result['total_documents'] ?? 0,
];
// Include PCA visualization coordinates if requested and available
if ($includePcaBool) {
$response['coordinates_3d'] = $result['coordinates_3d'] ?? [];
$response['query_coords'] = $result['query_coords'] ?? [];
if (isset($result['pca_variance'])) {
$response['pca_variance'] = $result['pca_variance'];
}
}
return new JSONResponse($response);
}
/**
* Get vector sync status from MCP server.
*
* AJAX endpoint for status refresh in personal settings.
*
* @return JSONResponse
*/
#[NoAdminRequired]
public function vectorStatus(): JSONResponse {
$status = $this->client->getVectorSyncStatus();
if (isset($status['error'])) {
return new JSONResponse([
'success' => false,
'error' => $status['error']
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
return new JSONResponse([
'success' => true,
'status' => $status
]);
}
/**
* Save admin search settings.
*
* Admin-only endpoint to configure AI Search provider parameters.
*
* @return JSONResponse
*/
public function saveSearchSettings(): JSONResponse {
// Parse JSON body
$input = file_get_contents('php://input');
$data = json_decode($input, true);
if ($data === null) {
return new JSONResponse([
'success' => false,
'error' => 'Invalid JSON body'
], Http::STATUS_BAD_REQUEST);
}
// Validate and save algorithm
$validAlgorithms = ['hybrid', 'semantic', 'bm25'];
$algorithm = $data['algorithm'] ?? AdminSettings::DEFAULT_SEARCH_ALGORITHM;
if (!in_array($algorithm, $validAlgorithms)) {
$algorithm = AdminSettings::DEFAULT_SEARCH_ALGORITHM;
}
$this->config->setAppValue(
$this->appName,
AdminSettings::SETTING_SEARCH_ALGORITHM,
$algorithm
);
// Validate and save fusion method
$validFusions = ['rrf', 'dbsf'];
$fusion = $data['fusion'] ?? AdminSettings::DEFAULT_SEARCH_FUSION;
if (!in_array($fusion, $validFusions)) {
$fusion = AdminSettings::DEFAULT_SEARCH_FUSION;
}
$this->config->setAppValue(
$this->appName,
AdminSettings::SETTING_SEARCH_FUSION,
$fusion
);
// Validate and save score threshold (0-100)
$scoreThreshold = (int)($data['scoreThreshold'] ?? AdminSettings::DEFAULT_SEARCH_SCORE_THRESHOLD);
$scoreThreshold = max(0, min(100, $scoreThreshold));
$this->config->setAppValue(
$this->appName,
AdminSettings::SETTING_SEARCH_SCORE_THRESHOLD,
(string)$scoreThreshold
);
// Validate and save limit (5-100)
$limit = (int)($data['limit'] ?? AdminSettings::DEFAULT_SEARCH_LIMIT);
$limit = max(5, min(100, $limit));
$this->config->setAppValue(
$this->appName,
AdminSettings::SETTING_SEARCH_LIMIT,
(string)$limit
);
$this->logger->info('Admin search settings saved', [
'algorithm' => $algorithm,
'fusion' => $fusion,
'scoreThreshold' => $scoreThreshold,
'limit' => $limit,
]);
return new JSONResponse([
'success' => true,
'settings' => [
'algorithm' => $algorithm,
'fusion' => $fusion,
'scoreThreshold' => $scoreThreshold,
'limit' => $limit,
]
]);
}
/**
* Get available webhook presets.
*
* Admin-only endpoint that lists webhook presets filtered by installed apps.
*
* @return JSONResponse
*/
public function getWebhookPresets(): JSONResponse {
// Get admin's OAuth token for API calls
$user = $this->userSession->getUser();
if (!$user) {
return new JSONResponse([
'success' => false,
'error' => 'User not authenticated'
], Http::STATUS_UNAUTHORIZED);
}
$userId = $user->getUID();
// Create refresh callback
$refreshCallback = function (string $refreshToken) {
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
if (!$newTokenData) {
return null;
}
return [
'access_token' => $newTokenData['access_token'],
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
'expires_in' => $newTokenData['expires_in'] ?? 3600,
];
};
// Get access token with automatic refresh
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
if (!$accessToken) {
return new JSONResponse([
'success' => false,
'error' => 'MCP server authorization required'
], Http::STATUS_UNAUTHORIZED);
}
// Get installed apps to filter presets
$installedAppsResult = $this->client->getInstalledApps($accessToken);
if (isset($installedAppsResult['error'])) {
return new JSONResponse([
'success' => false,
'error' => $installedAppsResult['error']
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
$installedApps = $installedAppsResult['apps'] ?? [];
// Get registered webhooks to check preset status
$webhooksResult = $this->client->listWebhooks($accessToken);
if (isset($webhooksResult['error'])) {
return new JSONResponse([
'success' => false,
'error' => $webhooksResult['error']
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
$registeredWebhooks = $webhooksResult['webhooks'] ?? [];
// Filter presets by installed apps
$presets = WebhookPresets::filterPresetsByInstalledApps($installedApps);
// Add enabled status to each preset
// IMPORTANT: Match both event type AND filter to avoid false positives
// (e.g., Notes and Files both use FILE_EVENT_* but with different filters)
$presetsWithStatus = [];
foreach ($presets as $presetId => $preset) {
// Check if all events for this preset are registered with matching filters
$allEventsRegistered = true;
foreach ($preset['events'] as $presetEvent) {
$eventMatched = false;
foreach ($registeredWebhooks as $webhook) {
// Match event type
if ($webhook['event'] !== $presetEvent['event']) {
continue;
}
// Match filter (both must have filter or both must not have filter)
$presetFilter = !empty($presetEvent['filter']) ? $presetEvent['filter'] : null;
$webhookFilter = !empty($webhook['eventFilter']) ? $webhook['eventFilter'] : null;
// Compare filters (use json_encode for deep comparison)
if (json_encode($presetFilter) === json_encode($webhookFilter)) {
$eventMatched = true;
break;
}
}
if (!$eventMatched) {
$allEventsRegistered = false;
break;
}
}
$presetsWithStatus[$presetId] = array_merge($preset, [
'enabled' => $allEventsRegistered
]);
}
return new JSONResponse([
'success' => true,
'presets' => $presetsWithStatus
]);
}
/**
* Enable a webhook preset.
*
* Admin-only endpoint that registers all webhooks for a preset.
*
* @param string $presetId Preset ID to enable
* @return JSONResponse
*/
public function enableWebhookPreset(string $presetId): JSONResponse {
// Get admin's OAuth token
$user = $this->userSession->getUser();
if (!$user) {
return new JSONResponse([
'success' => false,
'error' => 'User not authenticated'
], Http::STATUS_UNAUTHORIZED);
}
$userId = $user->getUID();
// Create refresh callback
$refreshCallback = function (string $refreshToken) {
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
if (!$newTokenData) {
return null;
}
return [
'access_token' => $newTokenData['access_token'],
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
'expires_in' => $newTokenData['expires_in'] ?? 3600,
];
};
// Get access token with automatic refresh
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
if (!$accessToken) {
return new JSONResponse([
'success' => false,
'error' => 'MCP server authorization required'
], Http::STATUS_UNAUTHORIZED);
}
// Get preset configuration
$preset = WebhookPresets::getPreset($presetId);
if ($preset === null) {
return new JSONResponse([
'success' => false,
'error' => "Unknown preset: $presetId"
], Http::STATUS_BAD_REQUEST);
}
// Get MCP server URL for webhook callback URI
$mcpServerUrl = $this->client->getServerUrl();
$callbackUri = $mcpServerUrl . '/api/v1/webhooks/callback';
// Register each event in the preset
$registered = [];
$errors = [];
foreach ($preset['events'] as $eventConfig) {
$result = $this->client->createWebhook(
$eventConfig['event'],
$callbackUri,
!empty($eventConfig['filter']) ? $eventConfig['filter'] : null,
$accessToken
);
if (isset($result['error'])) {
$errors[] = [
'event' => $eventConfig['event'],
'error' => $result['error']
];
} else {
$registered[] = $result;
}
}
if (!empty($errors)) {
return new JSONResponse([
'success' => false,
'error' => 'Failed to register some webhooks',
'registered' => $registered,
'errors' => $errors
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
$this->logger->info("Enabled webhook preset $presetId for user $userId", [
'preset_id' => $presetId,
'webhooks_registered' => count($registered)
]);
return new JSONResponse([
'success' => true,
'message' => "Enabled {$preset['name']}",
'webhooks' => $registered
]);
}
/**
* Disable a webhook preset.
*
* Admin-only endpoint that deletes all webhooks for a preset.
*
* @param string $presetId Preset ID to disable
* @return JSONResponse
*/
public function disableWebhookPreset(string $presetId): JSONResponse {
// Get admin's OAuth token
$user = $this->userSession->getUser();
if (!$user) {
return new JSONResponse([
'success' => false,
'error' => 'User not authenticated'
], Http::STATUS_UNAUTHORIZED);
}
$userId = $user->getUID();
// Create refresh callback
$refreshCallback = function (string $refreshToken) {
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
if (!$newTokenData) {
return null;
}
return [
'access_token' => $newTokenData['access_token'],
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
'expires_in' => $newTokenData['expires_in'] ?? 3600,
];
};
// Get access token with automatic refresh
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
if (!$accessToken) {
return new JSONResponse([
'success' => false,
'error' => 'MCP server authorization required'
], Http::STATUS_UNAUTHORIZED);
}
// Get preset configuration
$preset = WebhookPresets::getPreset($presetId);
if ($preset === null) {
return new JSONResponse([
'success' => false,
'error' => "Unknown preset: $presetId"
], Http::STATUS_BAD_REQUEST);
}
// Get all registered webhooks
$webhooksResult = $this->client->listWebhooks($accessToken);
if (isset($webhooksResult['error'])) {
return new JSONResponse([
'success' => false,
'error' => $webhooksResult['error']
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
$registeredWebhooks = $webhooksResult['webhooks'] ?? [];
// Find webhooks that match this preset's events AND filters
// IMPORTANT: Must match both event type AND filter to avoid deleting
// webhooks from other presets (e.g., Notes vs Files both use FILE_EVENT_*)
$webhooksToDelete = [];
foreach ($registeredWebhooks as $webhook) {
// Check if this webhook matches any event in the preset
foreach ($preset['events'] as $presetEvent) {
// Match event type
if ($webhook['event'] !== $presetEvent['event']) {
continue;
}
// Match filter (both must have filter or both must not have filter)
$presetFilter = !empty($presetEvent['filter']) ? $presetEvent['filter'] : null;
$webhookFilter = !empty($webhook['eventFilter']) ? $webhook['eventFilter'] : null;
// Compare filters (use json_encode for deep comparison)
if (json_encode($presetFilter) === json_encode($webhookFilter)) {
$webhooksToDelete[] = $webhook;
break; // This webhook matches, no need to check other preset events
}
}
}
// Delete each matching webhook
$deleted = [];
$errors = [];
foreach ($webhooksToDelete as $webhook) {
$result = $this->client->deleteWebhook($webhook['id'], $accessToken);
if (isset($result['error'])) {
$errors[] = [
'webhook_id' => $webhook['id'],
'event' => $webhook['event'],
'error' => $result['error']
];
} else {
$deleted[] = $webhook['id'];
}
}
if (!empty($errors)) {
return new JSONResponse([
'success' => false,
'error' => 'Failed to delete some webhooks',
'deleted' => $deleted,
'errors' => $errors
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
$this->logger->info("Disabled webhook preset $presetId for user $userId", [
'preset_id' => $presetId,
'webhooks_deleted' => count($deleted)
]);
return new JSONResponse([
'success' => true,
'message' => "Disabled {$preset['name']}",
'deleted' => $deleted
]);
}
/**
* Get chunk context for visualization.
*
* @param string $doc_type Document type
* @param string $doc_id Document ID
* @param int $start Start offset
* @param int $end End offset
* @return JSONResponse
*/
#[NoAdminRequired]
public function chunkContext(
string $doc_type,
string $doc_id,
int $start,
int $end,
): JSONResponse {
$user = $this->userSession->getUser();
if (!$user) {
return new JSONResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
$userId = $user->getUID();
// Create refresh callback
$refreshCallback = function (string $refreshToken) {
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
if (!$newTokenData) {
return null;
}
return [
'access_token' => $newTokenData['access_token'],
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
'expires_in' => $newTokenData['expires_in'] ?? 3600,
];
};
// Get user's OAuth token for MCP server with automatic refresh
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
if (!$accessToken) {
return new JSONResponse([
'success' => false,
'error' => 'MCP server authorization required.'
], Http::STATUS_UNAUTHORIZED);
}
$result = $this->client->getChunkContext($doc_type, $doc_id, $start, $end, $accessToken);
if (isset($result['error'])) {
return new JSONResponse(['success' => false, 'error' => $result['error']], Http::STATUS_INTERNAL_SERVER_ERROR);
}
return new JSONResponse($result);
}
}
+548
View File
@@ -0,0 +1,548 @@
<?php
declare(strict_types=1);
namespace OCA\Astrolabe\Controller;
use OCA\Astrolabe\Service\McpServerClient;
use OCA\Astrolabe\Service\McpTokenStorage;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IRequest;
use OCP\ISession;
use OCP\IURLGenerator;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
/**
* OAuth controller for MCP Server UI.
*
* Implements OAuth 2.0 Authorization Code flow with support for both:
* - Confidential clients (with client_secret): Direct token refresh, no PKCE
* - Public clients (without client_secret): PKCE-based flow for fallback
*/
class OAuthController extends Controller {
private $config;
private $session;
private $userSession;
private $urlGenerator;
private $tokenStorage;
private $logger;
private $l;
private $httpClient;
private $client;
public function __construct(
string $appName,
IRequest $request,
IConfig $config,
ISession $session,
IUserSession $userSession,
IURLGenerator $urlGenerator,
McpTokenStorage $tokenStorage,
LoggerInterface $logger,
IL10N $l,
IClientService $clientService,
McpServerClient $client,
) {
parent::__construct($appName, $request);
$this->config = $config;
$this->session = $session;
$this->userSession = $userSession;
$this->urlGenerator = $urlGenerator;
$this->tokenStorage = $tokenStorage;
$this->logger = $logger;
$this->l = $l;
$this->httpClient = $clientService->newClient();
$this->client = $client;
}
/**
* Initiate OAuth authorization flow.
*
* For confidential clients (with client_secret): Standard OAuth flow, no PKCE.
* For public clients (without client_secret): Generates PKCE code verifier and challenge.
*
* Stores state in session, then redirects user to IdP authorization endpoint.
*
* @return RedirectResponse|TemplateResponse
*/
#[NoAdminRequired]
#[NoCSRFRequired]
public function initiateOAuth() {
$this->logger->info('initiateOAuth called');
$user = $this->userSession->getUser();
if (!$user) {
$this->logger->error('initiateOAuth: User not authenticated');
return new TemplateResponse(
'astrolabe',
'settings/error',
['error' => $this->l->t('User not authenticated')]
);
}
$this->logger->info('initiateOAuth: User authenticated: ' . $user->getUID());
try {
// Get MCP server configuration
$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
if (empty($mcpServerUrl)) {
throw new \Exception('MCP server URL not configured');
}
// Check if confidential client secret is configured
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
$isConfidentialClient = !empty($clientSecret);
// Generate PKCE values only for public clients
$codeVerifier = null;
$codeChallenge = null;
if (!$isConfidentialClient) {
// Public client: use PKCE
$codeVerifier = bin2hex(random_bytes(32));
$codeChallenge = $this->base64UrlEncode(hash('sha256', $codeVerifier, true));
$this->logger->info('Using public client mode with PKCE');
} else {
$this->logger->info('Using confidential client mode with client secret');
}
// Generate state for CSRF protection
$state = bin2hex(random_bytes(16));
// Store values in session
if ($codeVerifier) {
$this->session->set('mcp_oauth_code_verifier', $codeVerifier);
}
$this->session->set('mcp_oauth_state', $state);
$this->session->set('mcp_oauth_user_id', $user->getUID());
// Build OAuth authorization URL
$authUrl = $this->buildAuthorizationUrl(
$mcpServerUrl,
$state,
$codeChallenge
);
$this->logger->info('Initiating OAuth flow for user: ' . $user->getUID());
return new RedirectResponse($authUrl);
} catch (\Exception $e) {
$this->logger->error('Failed to initiate OAuth flow', [
'error' => $e->getMessage()
]);
return new TemplateResponse(
'astrolabe',
'settings/error',
['error' => $this->l->t('Failed to initiate OAuth: %s', [$e->getMessage()])]
);
}
}
/**
* Handle OAuth callback after user authorization.
*
* Validates state, exchanges authorization code for access token using PKCE,
* and stores tokens for the user.
*
* @param string $code Authorization code
* @param string $state State parameter for CSRF protection
* @param string|null $error Error from IdP
* @param string|null $error_description Error description from IdP
* @return RedirectResponse
*/
#[NoAdminRequired]
#[NoCSRFRequired]
public function oauthCallback(
string $code = '',
string $state = '',
?string $error = null,
?string $error_description = null,
): RedirectResponse {
try {
// Check for errors from IdP
if ($error) {
throw new \Exception("OAuth error: $error - " . ($error_description ?? ''));
}
// Validate state to prevent CSRF
$storedState = $this->session->get('mcp_oauth_state');
if (empty($storedState) || $state !== $storedState) {
throw new \Exception('Invalid state parameter (CSRF protection)');
}
// Get stored PKCE verifier (may be null for confidential clients)
$codeVerifier = $this->session->get('mcp_oauth_code_verifier');
// Check if we have either client_secret or code_verifier
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
if (empty($clientSecret) && empty($codeVerifier)) {
throw new \Exception('Neither client secret nor code verifier available for authentication');
}
// Get user ID from session
$userId = $this->session->get('mcp_oauth_user_id');
if (empty($userId)) {
throw new \Exception('User ID not found in session');
}
// Get MCP server configuration
$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
if (empty($mcpServerUrl)) {
throw new \Exception('MCP server URL not configured');
}
// Exchange authorization code for tokens
$tokenData = $this->exchangeCodeForToken(
$mcpServerUrl,
$code,
$codeVerifier
);
// Store tokens for user
$this->tokenStorage->storeUserToken(
$userId,
$tokenData['access_token'],
$tokenData['refresh_token'] ?? '',
time() + ($tokenData['expires_in'] ?? 3600)
);
// Clean up session
$this->session->remove('mcp_oauth_code_verifier');
$this->session->remove('mcp_oauth_state');
$this->session->remove('mcp_oauth_user_id');
$this->logger->info("OAuth flow completed successfully for user: $userId");
// Redirect back to personal settings
return new RedirectResponse(
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
);
} catch (\Exception $e) {
$this->logger->error('OAuth callback failed', [
'error' => $e->getMessage()
]);
// Clean up session
$this->session->remove('mcp_oauth_code_verifier');
$this->session->remove('mcp_oauth_state');
$this->session->remove('mcp_oauth_user_id');
// Redirect to settings with error
return new RedirectResponse(
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', [
'section' => 'astrolabe',
'error' => urlencode($e->getMessage())
])
);
}
}
/**
* Disconnect user's MCP OAuth tokens.
*
* Deletes stored tokens from Nextcloud. Note: Does not revoke tokens on IdP side.
*
* @return RedirectResponse
*/
#[NoAdminRequired]
public function disconnect(): RedirectResponse {
$user = $this->userSession->getUser();
if (!$user) {
return new RedirectResponse(
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
);
}
$userId = $user->getUID();
try {
$this->tokenStorage->deleteUserToken($userId);
$this->logger->info("Disconnected MCP OAuth for user: $userId");
} catch (\Exception $e) {
$this->logger->error("Failed to disconnect MCP OAuth for user $userId", [
'error' => $e->getMessage()
]);
}
return new RedirectResponse(
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
);
}
/**
* Build OAuth authorization URL.
*
* Queries MCP server for IdP configuration, then performs OIDC discovery
* to find the authorization endpoint. Supports both Nextcloud OIDC and
* external IdPs like Keycloak.
*
* @param string $mcpServerUrl Base URL of MCP server
* @param string $state CSRF state parameter
* @param string|null $codeChallenge PKCE code challenge (null for confidential clients)
* @return string Authorization URL
* @throws \Exception if OIDC discovery fails
*/
private function buildAuthorizationUrl(
string $mcpServerUrl,
string $state,
?string $codeChallenge,
): string {
// First, query MCP server to discover which IdP it's configured to use
$this->logger->info('buildAuthorizationUrl: Starting', [
'mcp_server_url' => $mcpServerUrl,
]);
try {
$statusUrl = $mcpServerUrl . '/api/v1/status';
$this->logger->info('buildAuthorizationUrl: Fetching MCP server status', [
'url' => $statusUrl,
]);
$statusResponse = $this->httpClient->get($statusUrl);
$statusData = json_decode($statusResponse->getBody(), true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid JSON in status response: ' . json_last_error_msg());
}
$this->logger->info('buildAuthorizationUrl: MCP server status received', [
'auth_mode' => $statusData['auth_mode'] ?? 'unknown',
'has_oidc' => isset($statusData['oidc']),
'oidc_discovery_url' => $statusData['oidc']['discovery_url'] ?? 'not_set',
]);
} catch (\Exception $e) {
$this->logger->error('buildAuthorizationUrl: Failed to fetch MCP server status', [
'url' => $mcpServerUrl . '/api/v1/status',
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
throw new \Exception('Cannot connect to MCP server: ' . $e->getMessage());
}
// Determine OIDC discovery URL
// Priority: 1) MCP server's configured discovery URL, 2) Nextcloud OIDC app
if (isset($statusData['oidc']['discovery_url'])) {
// MCP server has external IdP configured (e.g., Keycloak)
$discoveryUrl = $statusData['oidc']['discovery_url'];
$this->logger->info('Using IdP from MCP server configuration', [
'discovery_url' => $discoveryUrl,
]);
} else {
// Fall back to Nextcloud's OIDC app
// Use internal localhost URL for HTTP request (always accessible from inside container)
// The OIDC discovery response will contain proper external URLs based on overwrite.cli.url
$discoveryUrl = 'http://localhost/.well-known/openid-configuration';
$this->logger->info('Using Nextcloud OIDC app as IdP (internal request)', [
'discovery_url' => $discoveryUrl,
]);
}
// Perform OIDC discovery
$this->logger->info('buildAuthorizationUrl: Starting OIDC discovery', [
'discovery_url' => $discoveryUrl,
]);
try {
$response = $this->httpClient->get($discoveryUrl);
$responseBody = $response->getBody();
$this->logger->info('buildAuthorizationUrl: Got OIDC discovery response', [
'status_code' => $response->getStatusCode(),
'body_length' => strlen($responseBody),
]);
$discovery = json_decode($responseBody, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid JSON in OIDC discovery: ' . json_last_error_msg());
}
if (!isset($discovery['authorization_endpoint'])) {
throw new \RuntimeException('Missing authorization_endpoint in OIDC discovery');
}
$authEndpoint = $discovery['authorization_endpoint'];
$this->logger->info('buildAuthorizationUrl: OIDC discovery succeeded', [
'auth_endpoint' => $authEndpoint,
'token_endpoint' => $discovery['token_endpoint'] ?? 'not_set',
]);
} catch (\Exception $e) {
$this->logger->error('buildAuthorizationUrl: OIDC discovery failed', [
'discovery_url' => $discoveryUrl,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
throw new \Exception('Failed to discover OAuth endpoints: ' . $e->getMessage());
}
// Build callback URL
$redirectUri = $this->urlGenerator->linkToRouteAbsolute(
'astrolabe.oauth.oauthCallback'
);
// Get public MCP server URL for token audience (RFC 8707 Resource Indicator)
// Use public URL that clients/browsers see, not internal Docker URL
$mcpServerPublicUrl = $this->config->getSystemValue('mcp_server_public_url', $mcpServerUrl);
// Build authorization URL parameters
$params = [
'client_id' => $this->client->getClientId(),
'redirect_uri' => $redirectUri,
'response_type' => 'code',
'scope' => 'openid profile email offline_access', // Request MCP scopes
'state' => $state,
'resource' => $mcpServerPublicUrl, // RFC 8707 Resource Indicator - request token with MCP server audience
];
// Add PKCE parameters only for public clients
if ($codeChallenge !== null) {
$params['code_challenge'] = $codeChallenge;
$params['code_challenge_method'] = 'S256';
}
return $authEndpoint . '?' . http_build_query($params);
}
/**
* Exchange authorization code for access token.
*
* For confidential clients: Uses client_secret for authentication.
* For public clients: Uses PKCE code_verifier for authentication.
*
* Queries MCP server for IdP configuration, then performs OIDC discovery
* to find the token endpoint. Supports both Nextcloud OIDC and external IdPs.
*
* @param string $mcpServerUrl Base URL of MCP server
* @param string $code Authorization code
* @param string|null $codeVerifier PKCE code verifier (null for confidential clients)
* @return array Token data containing access_token, refresh_token, expires_in
* @throws \Exception on HTTP or token error
*/
private function exchangeCodeForToken(
string $mcpServerUrl,
string $code,
?string $codeVerifier,
): array {
// Query MCP server to discover which IdP it's configured to use
try {
$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');
}
} catch (\Exception $e) {
$this->logger->error('Failed to fetch MCP server status during token exchange', [
'error' => $e->getMessage(),
]);
throw new \Exception('Cannot connect to MCP server: ' . $e->getMessage());
}
// Determine OIDC discovery URL and token endpoint
$useInternalNextcloud = !isset($statusData['oidc']['discovery_url']);
if (!$useInternalNextcloud) {
// External IdP configured - use discovery
$discoveryUrl = $statusData['oidc']['discovery_url'];
try {
$response = $this->httpClient->get($discoveryUrl);
$discovery = json_decode($response->getBody(), true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($discovery['token_endpoint'])) {
throw new \RuntimeException('Invalid OIDC discovery response');
}
$tokenEndpoint = $discovery['token_endpoint'];
} catch (\Exception $e) {
$this->logger->error('OIDC discovery failed during token exchange', [
'discovery_url' => $discoveryUrl,
'error' => $e->getMessage(),
]);
throw new \Exception('Failed to discover token endpoint: ' . $e->getMessage());
}
} else {
// Nextcloud's OIDC app - use internal URL directly (no HTTP request needed)
// This avoids network issues when overwritehost includes external port
$tokenEndpoint = 'http://localhost/apps/oidc/token';
}
$redirectUri = $this->urlGenerator->linkToRouteAbsolute(
'astrolabe.oauth.oauthCallback'
);
// Build token request parameters
$postData = [
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $redirectUri,
'client_id' => $this->client->getClientId(),
];
// Add client authentication based on client type
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
if (!empty($clientSecret)) {
// Confidential client: use client secret for authentication
$postData['client_secret'] = $clientSecret;
$this->logger->info('Using client secret for token exchange');
} elseif ($codeVerifier !== null) {
// Public client: use PKCE proof for authentication
$postData['code_verifier'] = $codeVerifier;
$this->logger->info('Using PKCE code verifier for token exchange');
} else {
throw new \Exception('Neither client_secret nor code_verifier available for token exchange');
}
// Use Nextcloud's HTTP client for token request
try {
$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 server');
}
return $tokenData;
} catch (\Exception $e) {
$this->logger->error('Token exchange failed', [
'error' => $e->getMessage(),
'token_endpoint' => $tokenEndpoint,
]);
throw new \Exception('Token exchange failed: ' . $e->getMessage());
}
}
/**
* Base64 URL-safe encoding (for PKCE).
*
* @param string $data Data to encode
* @return string Base64 URL-encoded string
*/
private function base64UrlEncode(string $data): string {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace OCA\Astrolabe\Controller;
use OCA\Astrolabe\AppInfo\Application;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\TemplateResponse;
/**
* @psalm-suppress UnusedClass
*/
class PageController extends Controller {
#[NoCSRFRequired]
#[NoAdminRequired]
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
#[FrontpageRoute(verb: 'GET', url: '/')]
public function index(): TemplateResponse {
return new TemplateResponse(
Application::APP_ID,
'index',
);
}
}
@@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
namespace OCA\Astrolabe\Search;
use OCA\Astrolabe\AppInfo\Application;
use OCA\Astrolabe\Service\McpServerClient;
use OCA\Astrolabe\Service\McpTokenStorage;
use OCA\Astrolabe\Settings\Admin as AdminSettings;
use OCP\Files\FileInfo;
use OCP\Files\IMimeTypeDetector;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IPreview;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\Search\IProvider;
use OCP\Search\ISearchQuery;
use OCP\Search\SearchResult;
use OCP\Search\SearchResultEntry;
use Psr\Log\LoggerInterface;
/**
* Unified Search provider for MCP Server semantic search.
*
* Delegates search queries to the MCP server's vector search API,
* returning semantically relevant results from indexed Nextcloud content
* (notes, files, calendar, deck cards).
*
* Security: Results are filtered server-side to only include documents
* owned by the searching user. User identity comes from OAuth token.
*/
class SemanticSearchProvider implements IProvider {
public function __construct(
private McpServerClient $client,
private McpTokenStorage $tokenStorage,
private IConfig $config,
private IL10N $l10n,
private IURLGenerator $urlGenerator,
private IMimeTypeDetector $mimeTypeDetector,
private IPreview $previewManager,
private LoggerInterface $logger,
) {
}
/**
* Unique identifier for this search provider.
*/
public function getId(): string {
return Application::APP_ID . '_semantic';
}
/**
* Display name shown in search results grouping.
*/
public function getName(): string {
return $this->l10n->t('Astrolabe');
}
/**
* Order in search results. Lower = higher priority.
* Use negative value when user is in our app's context.
*/
public function getOrder(string $route, array $routeParameters): int {
if (str_contains($route, Application::APP_ID)) {
return -1; // Prioritize when in Astrolabe app
}
return 40; // Above most apps, below files/mail
}
/**
* Execute semantic search via MCP server.
*
* SECURITY: Results are filtered server-side to only include documents
* owned by the searching user. User identity comes from OAuth token.
*/
public function search(IUser $user, ISearchQuery $query): SearchResult {
$term = $query->getTerm();
$limit = $query->getLimit();
$cursor = $query->getCursor();
// Skip empty queries
if (empty(trim($term))) {
return SearchResult::complete($this->getName(), []);
}
// Get OAuth token for user
$accessToken = $this->tokenStorage->getAccessToken($user->getUID());
if ($accessToken === null) {
// User hasn't authorized the app yet - return empty results
$this->logger->debug('No OAuth token for user in semantic search', [
'user_id' => $user->getUID(),
]);
return SearchResult::complete($this->getName(), []);
}
// Check if MCP server is available and vector sync enabled
$status = $this->client->getStatus();
if (!empty($status['error']) || !($status['vector_sync_enabled'] ?? false)) {
$this->logger->debug('MCP server not available or vector sync disabled', [
'status' => $status,
]);
return SearchResult::complete($this->getName(), []);
}
// Load admin search settings
$algorithm = $this->config->getAppValue(
Application::APP_ID,
AdminSettings::SETTING_SEARCH_ALGORITHM,
AdminSettings::DEFAULT_SEARCH_ALGORITHM
);
$fusion = $this->config->getAppValue(
Application::APP_ID,
AdminSettings::SETTING_SEARCH_FUSION,
AdminSettings::DEFAULT_SEARCH_FUSION
);
$scoreThreshold = (int)$this->config->getAppValue(
Application::APP_ID,
AdminSettings::SETTING_SEARCH_SCORE_THRESHOLD,
(string)AdminSettings::DEFAULT_SEARCH_SCORE_THRESHOLD
);
$configuredLimit = (int)$this->config->getAppValue(
Application::APP_ID,
AdminSettings::SETTING_SEARCH_LIMIT,
(string)AdminSettings::DEFAULT_SEARCH_LIMIT
);
// Use configured limit if query limit is higher
$effectiveLimit = min($limit, $configuredLimit);
// Calculate offset from cursor
$offset = $cursor ? (int)$cursor : 0;
// Execute semantic search with OAuth token and admin settings
// Server extracts user_id from token - results filtered to that user's documents
$results = $this->client->searchForUnifiedSearch(
query: $term,
token: $accessToken,
limit: $effectiveLimit,
offset: $offset,
algorithm: $algorithm,
fusion: $fusion,
scoreThreshold: $scoreThreshold / 100.0, // Convert percentage to 0-1 range
);
if (!empty($results['error'])) {
$this->logger->warning('Semantic search failed', [
'error' => $results['error'],
'query' => $term,
]);
return SearchResult::complete($this->getName(), []);
}
// Transform results to SearchResultEntry objects
$entries = [];
foreach ($results['results'] ?? [] as $result) {
$entries[] = $this->transformResult($result);
}
// Return paginated if more results might exist
$totalFound = $results['total_found'] ?? count($entries);
if (count($entries) >= $effectiveLimit && $totalFound > $offset + $effectiveLimit) {
return SearchResult::paginated(
$this->getName(),
$entries,
(string)($offset + $effectiveLimit)
);
}
return SearchResult::complete($this->getName(), $entries);
}
/**
* Transform MCP search result to Nextcloud SearchResultEntry.
*/
private function transformResult(array $result): SearchResultEntry {
$docType = $result['doc_type'] ?? 'unknown';
$title = $result['title'] ?? $this->l10n->t('Untitled');
$score = $result['score'] ?? 0;
$id = isset($result['id']) ? (string)$result['id'] : null;
$mimeType = $result['mime_type'] ?? null;
// Build resource URL based on document type
$resourceUrl = $this->buildResourceUrl($result);
// Get icon and thumbnail based on document type
[$thumbnailUrl, $iconClass] = $this->getIconAndThumbnail($docType, $id, $mimeType);
// Build metadata string with chunk and page info
$metadataParts = [];
// Chunk info (always available)
if (isset($result['chunk_index']) && isset($result['total_chunks'])) {
$chunkNum = $result['chunk_index'] + 1; // Convert 0-based to 1-based
$metadataParts[] = sprintf('Chunk %d/%d', $chunkNum, $result['total_chunks']);
}
// Page info for PDFs
if (!empty($result['page_number']) && !empty($result['page_count'])) {
$metadataParts[] = sprintf('Page %d/%d', $result['page_number'], $result['page_count']);
}
// Combine metadata parts
$metadata = !empty($metadataParts) ? implode(' · ', $metadataParts) : '';
// Subline shows only chunk/page metadata (no excerpt, consistent with chunk viz)
$subline = $metadata ?: sprintf(
'%s · %d%% %s',
$this->getDocTypeLabel($docType),
(int)($score * 100),
$this->l10n->t('relevant')
);
return new SearchResultEntry(
$thumbnailUrl,
$title,
$subline,
$resourceUrl,
$iconClass,
false // not rounded
);
}
/**
* Build URL to navigate to the original document.
*
* URL formats match App.vue's getDocumentUrl() implementation for consistency.
*/
private function buildResourceUrl(array $result): string {
$docType = $result['doc_type'] ?? 'unknown';
$id = $result['id'] ?? null;
$path = $result['path'] ?? null;
return match ($docType) {
'note' => $id
? $this->urlGenerator->linkToRoute('notes.page.index') . '/#/note/' . $id
: $this->urlGenerator->linkToRoute('notes.page.index'),
'file' => $id
? $this->urlGenerator->linkToRouteAbsolute('files.view.index') . 'files/' . $id . '?dir=/&editing=false&openfile=true'
: $this->urlGenerator->linkToRouteAbsolute('files.view.index'),
'deck_card' => isset($result['board_id']) && $id
? $this->urlGenerator->linkToRoute('deck.page.index')
. "board/{$result['board_id']}/card/{$id}"
: $this->urlGenerator->linkToRoute('deck.page.index'),
'calendar', 'calendar_event' => $this->urlGenerator->linkToRoute('calendar.view.index'),
'news_item' => $id
? $this->urlGenerator->linkToRoute('news.page.index') . 'item/' . $id
: $this->urlGenerator->linkToRoute('news.page.index'),
'contact' => $this->urlGenerator->linkToRoute('contacts.page.index'),
default => $this->urlGenerator->linkToRoute(Application::APP_ID . '.page.index'),
};
}
/**
* Get icon and thumbnail for document type.
*
* Returns [thumbnailUrl, iconClass] tuple.
* For files, uses mimetype-specific icons and preview thumbnails when available.
* For other document types, uses appropriate icon classes.
*
* @return array{string, string} [thumbnailUrl, iconClass]
*/
private function getIconAndThumbnail(string $docType, ?string $id, ?string $mimeType): array {
if ($docType === 'file' && $id !== null && $mimeType !== null) {
// For files, check if preview is supported
$thumbnailUrl = '';
if ($this->previewManager->isMimeSupported($mimeType)) {
$thumbnailUrl = $this->urlGenerator->linkToRouteAbsolute(
'core.Preview.getPreviewByFileId',
['x' => 32, 'y' => 32, 'fileId' => $id]
);
}
// Get mimetype-specific icon class
$iconClass = $mimeType === FileInfo::MIMETYPE_FOLDER
? 'icon-folder'
: $this->mimeTypeDetector->mimeTypeIcon($mimeType);
return [$thumbnailUrl, $iconClass];
}
// For non-file document types, use icon classes
$iconClass = match ($docType) {
'note' => 'icon-notes',
'deck_card' => 'icon-deck',
'calendar', 'calendar_event' => 'icon-calendar',
'news_item' => 'icon-rss',
'contact' => 'icon-contacts',
default => 'icon-file',
};
return ['', $iconClass];
}
/**
* Get human-readable label for document type.
*/
private function getDocTypeLabel(string $docType): string {
return match ($docType) {
'note' => $this->l10n->t('Note'),
'file' => $this->l10n->t('File'),
'deck_card' => $this->l10n->t('Deck Card'),
'calendar', 'calendar_event' => $this->l10n->t('Calendar'),
'news_item' => $this->l10n->t('News'),
'contact' => $this->l10n->t('Contact'),
default => $this->l10n->t('Document'),
};
}
}
+133
View File
@@ -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;
}
}
}
+606
View File
@@ -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()];
}
}
}
+205
View File
@@ -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'];
}
}
+188
View File
@@ -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;
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace OCA\Astrolabe\Settings;
use OCA\Astrolabe\AppInfo\Application;
use OCA\Astrolabe\Service\McpServerClient;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IConfig;
use OCP\Settings\ISettings;
/**
* Admin settings panel for Astrolabe.
*
* Displays semantic search service status, indexing metrics,
* configuration, and provides administrative controls.
*/
class Admin implements ISettings {
// Search settings keys and defaults
public const SETTING_SEARCH_ALGORITHM = 'search_algorithm';
public const SETTING_SEARCH_FUSION = 'search_fusion';
public const SETTING_SEARCH_SCORE_THRESHOLD = 'search_score_threshold';
public const SETTING_SEARCH_LIMIT = 'search_limit';
public const DEFAULT_SEARCH_ALGORITHM = 'hybrid';
public const DEFAULT_SEARCH_FUSION = 'rrf';
public const DEFAULT_SEARCH_SCORE_THRESHOLD = 0;
public const DEFAULT_SEARCH_LIMIT = 20;
private $client;
private $config;
private $initialState;
public function __construct(
McpServerClient $client,
IConfig $config,
IInitialState $initialState,
) {
$this->client = $client;
$this->config = $config;
$this->initialState = $initialState;
}
/**
* @return TemplateResponse
*/
public function getForm(): TemplateResponse {
// Fetch data from MCP server
$serverStatus = $this->client->getStatus();
$vectorSyncStatus = $this->client->getVectorSyncStatus();
// Get configuration from config.php
$serverUrl = $this->config->getSystemValue('mcp_server_url', '');
$apiKeyConfigured = !empty($this->config->getSystemValue('mcp_server_api_key', ''));
$clientId = $this->config->getSystemValue('astrolabe_client_id', '');
$clientIdConfigured = !empty($clientId);
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
$clientSecretConfigured = !empty($clientSecret);
// Check for server connection error
if (isset($serverStatus['error'])) {
return new TemplateResponse(
Application::APP_ID,
'settings/error',
[
'error' => 'Cannot connect to MCP server',
'details' => $serverStatus['error'],
'server_url' => $serverUrl,
'help_text' => 'Ensure MCP server is running and accessible. Check config.php for correct mcp_server_url.',
],
TemplateResponse::RENDER_AS_BLANK
);
}
// Load search settings from app config
$searchSettings = [
'algorithm' => $this->config->getAppValue(
Application::APP_ID,
self::SETTING_SEARCH_ALGORITHM,
self::DEFAULT_SEARCH_ALGORITHM
),
'fusion' => $this->config->getAppValue(
Application::APP_ID,
self::SETTING_SEARCH_FUSION,
self::DEFAULT_SEARCH_FUSION
),
'scoreThreshold' => (int)$this->config->getAppValue(
Application::APP_ID,
self::SETTING_SEARCH_SCORE_THRESHOLD,
(string)self::DEFAULT_SEARCH_SCORE_THRESHOLD
),
'limit' => (int)$this->config->getAppValue(
Application::APP_ID,
self::SETTING_SEARCH_LIMIT,
(string)self::DEFAULT_SEARCH_LIMIT
),
];
// Provide initial state for Vue.js frontend (if needed)
$this->initialState->provideInitialState('server-data', [
'serverStatus' => $serverStatus,
'vectorSyncStatus' => $vectorSyncStatus,
'config' => [
'serverUrl' => $serverUrl,
'apiKeyConfigured' => $apiKeyConfigured,
],
'searchSettings' => $searchSettings,
]);
$parameters = [
'serverStatus' => $serverStatus,
'vectorSyncStatus' => $vectorSyncStatus,
'serverUrl' => $serverUrl,
'apiKeyConfigured' => $apiKeyConfigured,
'clientIdConfigured' => $clientIdConfigured,
'clientSecretConfigured' => $clientSecretConfigured,
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
'searchSettings' => $searchSettings,
];
return new TemplateResponse(
Application::APP_ID,
'settings/admin',
$parameters,
TemplateResponse::RENDER_AS_BLANK
);
}
/**
* @return string The section ID
*/
public function getSection(): string {
return 'astrolabe';
}
/**
* @return int Priority (lower = higher up)
*/
public function getPriority(): int {
return 10;
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace OCA\Astrolabe\Settings;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\Settings\IIconSection;
/**
* Admin settings section for Astrolabe.
*
* Creates a dedicated section in admin settings for semantic search administration.
*/
class AdminSection implements IIconSection {
private $l;
private $urlGenerator;
public function __construct(IL10N $l, IURLGenerator $urlGenerator) {
$this->l = $l;
$this->urlGenerator = $urlGenerator;
}
/**
* @return string The section ID
*/
public function getID(): string {
return 'astrolabe';
}
/**
* @return string The translated section name
*/
public function getName(): string {
return $this->l->t('Astrolabe');
}
/**
* @return int Priority (lower = higher up in list)
*/
public function getPriority(): int {
return 80;
}
/**
* @return string Section icon (SVG or image URL)
*/
public function getIcon(): string {
return $this->urlGenerator->imagePath('astrolabe', 'app-dark.svg');
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace OCA\Astrolabe\Settings;
use OCA\Astrolabe\AppInfo\Application;
use OCA\Astrolabe\Service\McpServerClient;
use OCA\Astrolabe\Service\McpTokenStorage;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IURLGenerator;
use OCP\IUserSession;
use OCP\Settings\ISettings;
/**
* Personal settings panel for Astrolabe.
*
* Displays semantic search status, background indexing access,
* and provides controls for managing content indexing.
*
* Uses OAuth PKCE flow - each user must authorize background access.
*/
class Personal implements ISettings {
private $client;
private $userSession;
private $initialState;
private $tokenStorage;
private $urlGenerator;
public function __construct(
McpServerClient $client,
IUserSession $userSession,
IInitialState $initialState,
McpTokenStorage $tokenStorage,
IURLGenerator $urlGenerator,
) {
$this->client = $client;
$this->userSession = $userSession;
$this->initialState = $initialState;
$this->tokenStorage = $tokenStorage;
$this->urlGenerator = $urlGenerator;
}
/**
* @return TemplateResponse
*/
public function getForm(): TemplateResponse {
$user = $this->userSession->getUser();
if (!$user) {
return new TemplateResponse(Application::APP_ID, 'settings/error', [
'error' => 'User not authenticated'
], TemplateResponse::RENDER_AS_BLANK);
}
$userId = $user->getUID();
// Check if user has MCP OAuth token
$token = $this->tokenStorage->getUserToken($userId);
// If no token or token is expired, show OAuth authorization UI
if (!$token || $this->tokenStorage->isExpired($token)) {
$oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth');
return new TemplateResponse(
Application::APP_ID,
'settings/oauth-required',
[
'oauth_url' => $oauthUrl,
'server_url' => $this->client->getPublicServerUrl(),
'has_expired' => ($token !== null), // true if token exists but expired
],
TemplateResponse::RENDER_AS_BLANK
);
}
// User has valid token - fetch data from MCP server
$accessToken = $token['access_token'];
// Fetch server status (public endpoint, no token needed)
$serverStatus = $this->client->getStatus();
// Fetch user session data (requires token)
$userSession = $this->client->getUserSession($userId, $accessToken);
// Check for server connection error
if (isset($serverStatus['error'])) {
return new TemplateResponse(
Application::APP_ID,
'settings/error',
[
'error' => 'Cannot connect to MCP server',
'details' => $serverStatus['error'],
'server_url' => $this->client->getPublicServerUrl(),
],
TemplateResponse::RENDER_AS_BLANK
);
}
// Check for authentication error (invalid/expired token)
if (isset($userSession['error'])) {
// Token might be invalid - delete it and show OAuth UI
$this->tokenStorage->deleteUserToken($userId);
$oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth');
return new TemplateResponse(
Application::APP_ID,
'settings/oauth-required',
[
'oauth_url' => $oauthUrl,
'server_url' => $this->client->getPublicServerUrl(),
'has_expired' => true,
'error_message' => 'Your session has expired. Please sign in again.',
],
TemplateResponse::RENDER_AS_BLANK
);
}
// Provide initial state for Vue.js frontend (if needed)
$this->initialState->provideInitialState('user-data', [
'userId' => $userId,
'serverStatus' => $serverStatus,
'session' => $userSession,
]);
$parameters = [
'userId' => $userId,
'serverStatus' => $serverStatus,
'session' => $userSession,
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
'backgroundAccessGranted' => $userSession['background_access_granted'] ?? false,
'serverUrl' => $this->client->getPublicServerUrl(),
'hasToken' => true,
];
return new TemplateResponse(
Application::APP_ID,
'settings/personal',
$parameters,
TemplateResponse::RENDER_AS_BLANK
);
}
/**
* @return string The section ID
*/
public function getSection(): string {
return 'astrolabe';
}
/**
* @return int Priority (lower = higher up)
*/
public function getPriority(): int {
return 50;
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace OCA\Astrolabe\Settings;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\Settings\IIconSection;
/**
* Personal settings section for Astrolabe.
*
* Creates a dedicated section in personal settings for semantic search configuration.
*/
class PersonalSection implements IIconSection {
private $l;
private $urlGenerator;
public function __construct(IL10N $l, IURLGenerator $urlGenerator) {
$this->l = $l;
$this->urlGenerator = $urlGenerator;
}
/**
* @return string The section ID
*/
public function getID(): string {
return 'astrolabe';
}
/**
* @return string The translated section name
*/
public function getName(): string {
return $this->l->t('Astrolabe');
}
/**
* @return int Priority (lower = higher up in list, 0-99)
*/
public function getPriority(): int {
return 80;
}
/**
* @return string Section icon (SVG or image URL)
*/
public function getIcon(): string {
return $this->urlGenerator->imagePath('astrolabe', 'app-dark.svg');
}
}