trim($this->config->getAppValue($appId, $key, $default)); $data = [ 'sovereignMode' => $get('sc_mode', 'powered'), 'chd' => [ 'baseUrl' => $get('sc_chd_base_url', ''), 'instanceId' => $get('sc_chd_instance_id', ''), 'instanceTokenSet' => $get('sc_chd_instance_token', '') !== '', 'registrationTokenSet' => $get('sc_chd_registration_token', '') !== '', 'connectorAdminTokenSet' => $get('sc_chd_admin_api_token', '') !== '', 'billingProvider' => $get('sc_billing_provider', 'paymenter'), 'billingSyncMode' => $get('sc_billing_sync_mode', 'read_only'), 'lastSyncAt' => $get('sc_chd_last_sync_at', ''), 'lastSyncStatus' => $get('sc_chd_last_sync_status', ''), 'lastSyncMessage' => $get('sc_chd_last_sync_message', ''), ], 'billingAutomation' => $this->billingAutoFulfillmentService->getOverview(), 'entitlements' => [ 'packageTier' => $get('sc_package_tier', 'free_core'), 'creditsBalance' => $get('sc_credits_balance', '0'), 'monthlyCredits' => $get('sc_monthly_credits', '0'), 'encryptedCapacityGb' => $get('sc_encrypted_capacity_gb', '0'), 'replicationTarget' => $get('sc_replication_target', '0'), ], 'designStatus' => [ 'installations' => 'planned', 'catalog' => 'planned', 'customerAccounts' => 'planned', 'auditLogs' => 'planned', ], ]; return new DataResponse([ 'ok' => true, 'data' => $data, ]); } /** * @AdminRequired */ public function connectorSummary(): DataResponse { try { $data = $this->fetchConnectorSummary(); return new DataResponse([ 'ok' => true, 'data' => $data, ]); } catch (\Throwable $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 502); } } /** * @AdminRequired */ public function connectorRoutingConfig(): DataResponse { return new DataResponse([ 'ok' => true, 'data' => $this->buildConnectorRoutingConfigResponse(), ]); } /** * @AdminRequired */ public function saveBillingAutoFulfillment(): DataResponse { $payload = $this->readJsonPayload(); $enabledRaw = $payload['enabled'] ?? $this->request->getParam('enabled', ''); $enabled = filter_var($enabledRaw, FILTER_VALIDATE_BOOLEAN); return new DataResponse([ 'ok' => true, 'data' => $this->billingAutoFulfillmentService->saveEnabled($enabled), ]); } /** * @AdminRequired */ public function runBillingAutoFulfillment(): DataResponse { return new DataResponse([ 'ok' => true, 'data' => $this->billingAutoFulfillmentService->runCycle(true), ]); } /** * @AdminRequired */ public function backfillBillingAutoFulfillment(): DataResponse { $payload = $this->readJsonPayload(); $since = trim((string)($payload['since'] ?? $this->request->getParam('since', ''))); $invoiceId = trim((string)($payload['invoiceId'] ?? $payload['invoice_id'] ?? $this->request->getParam('invoiceId', $this->request->getParam('invoice_id', '')))); $result = $this->billingAutoFulfillmentService->runBackfill($since, $invoiceId); return new DataResponse([ 'ok' => !empty($result['ok']), 'data' => $result, 'error' => empty($result['ok']) ? (string)($result['message'] ?? 'Backfill failed') : null, ], empty($result['ok']) ? 400 : 200); } /** * @AdminRequired */ public function saveConnectorAdminToken(): DataResponse { $currentToken = trim((string)$this->config->getAppValue( self::QORTAL_INTEGRATION_APP_ID, self::QORTAL_INTEGRATION_KEY_CHD_ADMIN_API_TOKEN, '' )); if ($currentToken !== '') { return new DataResponse([ 'ok' => false, 'error' => 'Connector admin token is already configured', ], 409); } $payload = $this->readJsonPayload(); $token = trim((string)( $payload['connectorAdminToken'] ?? $payload['adminToken'] ?? $this->request->getParam('connectorAdminToken', $this->request->getParam('adminToken', '')) )); if ($token === '') { return new DataResponse([ 'ok' => false, 'error' => 'connectorAdminToken is required', ], 400); } $this->config->setAppValue( self::QORTAL_INTEGRATION_APP_ID, self::QORTAL_INTEGRATION_KEY_CHD_ADMIN_API_TOKEN, $token ); return new DataResponse([ 'ok' => true, 'data' => [ 'connectorAdminTokenSet' => true, ], ]); } /** * @AdminRequired */ public function saveConnectorRoutingConfig(): DataResponse { $payload = $this->readJsonPayload(); $config = $this->normalizeConnectorRoutingConfig( is_array($payload['config'] ?? null) ? $payload['config'] : $payload ); $activeProfile = trim((string)($config['activeProfile'] ?? '')); $activeBaseUrl = trim((string)($config['profiles'][$activeProfile]['baseUrl'] ?? '')); if ($activeProfile !== '' && $activeBaseUrl === '') { return new DataResponse([ 'ok' => false, 'error' => 'Selected connector mode is missing a base URL', ], 400); } $this->config->setAppValue( self::APP_ID, self::KEY_CONNECTOR_ROUTING_CONFIG, json_encode($config, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}' ); if ($activeBaseUrl !== '') { $this->config->setAppValue( self::QORTAL_INTEGRATION_APP_ID, self::QORTAL_INTEGRATION_KEY_CHD_BASE_URL, $activeBaseUrl ); } return new DataResponse([ 'ok' => true, 'data' => $this->buildConnectorRoutingConfigResponse(), ]); } /** * @AdminRequired */ public function connectorInstances(): DataResponse { try { $limit = (int)$this->request->getParam('limit', 100); $offset = (int)$this->request->getParam('offset', 0); $search = trim((string)$this->request->getParam('search', '')); $removedFlag = strtolower(trim((string)$this->request->getParam('removed', ''))); $removed = in_array($removedFlag, ['1', 'true', 'yes', 'removed'], true); $data = $this->fetchConnectorInstances($limit, $offset, $search, $removed); return new DataResponse([ 'ok' => true, 'data' => $data, ]); } catch (\Throwable $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 502); } } /** * @AdminRequired */ public function connectorInstanceDetail(): DataResponse { $instanceId = trim((string)$this->request->getParam('instanceId', '')); if ($instanceId === '') { return new DataResponse([ 'ok' => false, 'error' => 'instanceId is required', ], 400); } try { $data = $this->fetchConnectorInstanceDetail($instanceId); return new DataResponse([ 'ok' => true, 'data' => $data, ]); } catch (\Throwable $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 502); } } /** * @AdminRequired */ public function paymenterClients(): DataResponse { try { $limit = (int)$this->request->getParam('limit', 50); $offset = (int)$this->request->getParam('offset', 0); $search = trim((string)$this->request->getParam('search', '')); $paymenterProfile = trim((string)$this->request->getParam('paymenterProfile', '')); $data = $this->fetchConnectorPaymenterClients($paymenterProfile, $limit, $offset, $search); return new DataResponse([ 'ok' => true, 'data' => $data, ]); } catch (\Throwable $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 502); } } /** * @AdminRequired */ public function paymenterOrders(): DataResponse { try { $limit = (int)$this->request->getParam('limit', 100); $offset = (int)$this->request->getParam('offset', 0); $status = trim((string)$this->request->getParam('status', '')); $customerId = trim((string)$this->request->getParam('customerId', '')); $instanceId = trim((string)$this->request->getParam('instanceId', '')); $since = trim((string)$this->request->getParam('since', $this->request->getParam('updated_since', ''))); $paymenterProfile = trim((string)$this->request->getParam('paymenterProfile', '')); $data = $this->fetchConnectorPaymenterOrders($paymenterProfile, $limit, $offset, $status, $customerId, $instanceId, $since); return new DataResponse([ 'ok' => true, 'data' => $data, ]); } catch (\Throwable $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 502); } } /** * @AdminRequired */ public function paymenterSnapshot(): DataResponse { try { $payload = [ 'instanceId' => trim((string)$this->request->getParam('instanceId', '')), 'customerId' => trim((string)$this->request->getParam('customerId', '')), 'requestedEmail' => trim((string)$this->request->getParam('requestedEmail', '')), 'paymenterProfile' => trim((string)$this->request->getParam('paymenterProfile', '')), ]; $data = $this->fetchConnectorPaymenterSnapshot($payload); return new DataResponse([ 'ok' => true, 'data' => $data, ]); } catch (\Throwable $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 502); } } /** * @AdminRequired */ public function paymenterSourceSync(): DataResponse { $payload = $this->readJsonPayload(); try { $data = $this->runConnectorPaymenterSourceSync([ 'instanceId' => trim((string)($payload['instanceId'] ?? $this->request->getParam('instanceId', ''))), 'paymenterProfile' => trim((string)($payload['paymenterProfile'] ?? $this->request->getParam('paymenterProfile', ''))), 'dryRun' => !empty($payload['dryRun']) || $this->request->getParam('dryRun', '') === '1', ]); return new DataResponse([ 'ok' => true, 'data' => $data, ]); } catch (\Throwable $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 502); } } /** * @AdminRequired */ public function paymenterBackups(): DataResponse { try { $instanceId = trim((string)$this->request->getParam('instanceId', '')); $limit = (int)$this->request->getParam('limit', 20); $offset = (int)$this->request->getParam('offset', 0); $search = trim((string)$this->request->getParam('search', '')); $data = $this->fetchConnectorPaymenterBackups($instanceId, $limit, $offset, $search); return new DataResponse([ 'ok' => true, 'data' => $data, ]); } catch (\Throwable $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 502); } } /** * @AdminRequired */ public function paymenterInvoices(): DataResponse { try { $limit = (int)$this->request->getParam('limit', 100); $offset = (int)$this->request->getParam('offset', 0); $status = trim((string)$this->request->getParam('status', '')); $customerId = trim((string)$this->request->getParam('customerId', '')); $instanceId = trim((string)$this->request->getParam('instanceId', '')); $since = trim((string)$this->request->getParam('since', $this->request->getParam('updated_since', ''))); $search = trim((string)$this->request->getParam('search', '')); $paymenterProfile = trim((string)$this->request->getParam('paymenterProfile', '')); $data = $this->fetchConnectorPaymenterInvoices($paymenterProfile, $limit, $offset, $status, $customerId, $instanceId, $since, $search); return new DataResponse([ 'ok' => true, 'data' => $data, ]); } catch (\Throwable $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 502); } } /** * @AdminRequired */ public function paymenterInvoiceItems(): DataResponse { try { $limit = (int)$this->request->getParam('limit', 100); $offset = (int)$this->request->getParam('offset', 0); $search = trim((string)$this->request->getParam('search', '')); $referenceType = trim((string)$this->request->getParam('referenceType', $this->request->getParam('reference_type', ''))); $referenceId = trim((string)$this->request->getParam('referenceId', $this->request->getParam('reference_id', ''))); $paymenterProfile = trim((string)$this->request->getParam('paymenterProfile', '')); $data = $this->fetchConnectorPaymenterInvoiceItems($paymenterProfile, $limit, $offset, $search, $referenceType, $referenceId); return new DataResponse([ 'ok' => true, 'data' => $data, ]); } catch (\Throwable $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 502); } } /** * @AdminRequired */ public function saveConnectorInstanceTarget(): DataResponse { $payload = $this->readJsonPayload(); $instanceId = trim((string)($payload['instanceId'] ?? $this->request->getParam('instanceId', ''))); if ($instanceId === '') { return new DataResponse([ 'ok' => false, 'error' => 'instanceId is required', ], 400); } try { $data = $this->saveConnectorInstanceTargetRemote($instanceId, [ 'connectorProfile' => trim((string)($payload['connectorProfile'] ?? $this->request->getParam('connectorProfile', ''))), 'connectorBaseUrl' => trim((string)($payload['connectorBaseUrl'] ?? $this->request->getParam('connectorBaseUrl', ''))), ]); return new DataResponse([ 'ok' => true, 'data' => $data, ]); } catch (\Throwable $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 502); } } /** * @AdminRequired */ public function saveConnectorInstanceMetadata(): DataResponse { $payload = $this->readJsonPayload(); $instanceId = trim((string)($payload['instanceId'] ?? $this->request->getParam('instanceId', ''))); if ($instanceId === '') { return new DataResponse([ 'ok' => false, 'error' => 'instanceId is required', ], 400); } try { $data = $this->saveConnectorInstanceMetadataRemote($instanceId, [ 'mspContactName' => trim((string)($payload['mspContactName'] ?? $this->request->getParam('mspContactName', ''))), 'mspContactEmail' => trim((string)($payload['mspContactEmail'] ?? $this->request->getParam('mspContactEmail', ''))), 'pluginAdminEmail' => trim((string)($payload['pluginAdminEmail'] ?? $this->request->getParam('pluginAdminEmail', ''))), 'adminQortalAddress' => trim((string)($payload['adminQortalAddress'] ?? $this->request->getParam('adminQortalAddress', ''))), ]); return new DataResponse([ 'ok' => true, 'data' => $data, ]); } catch (\Throwable $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 502); } } /** * @AdminRequired */ public function saveConnectorInstanceState(): DataResponse { $payload = $this->readJsonPayload(); $instanceId = trim((string)($payload['instanceId'] ?? $this->request->getParam('instanceId', ''))); $action = trim((string)($payload['action'] ?? $this->request->getParam('action', ''))); if ($instanceId === '') { return new DataResponse([ 'ok' => false, 'error' => 'instanceId is required', ], 400); } if ($action === '') { return new DataResponse([ 'ok' => false, 'error' => 'action is required', ], 400); } try { $data = $this->saveConnectorInstanceStateRemote($instanceId, $action, [ 'reason' => trim((string)($payload['reason'] ?? $this->request->getParam('reason', ''))), ]); return new DataResponse([ 'ok' => true, 'data' => $data, ]); } catch (\Throwable $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 502); } } /** * @AdminRequired */ public function instanceOperations(): DataResponse { $instanceId = trim((string)$this->request->getParam('instanceId', '')); if ($instanceId === '') { return new DataResponse([ 'ok' => false, 'error' => 'instanceId is required', ], 400); } $tasks = $this->listInstanceOperations($instanceId); return new DataResponse([ 'ok' => true, 'data' => [ 'instanceId' => $instanceId, 'tasks' => $tasks, ], ]); } /** * @AdminRequired */ public function saveInstanceOperation(): DataResponse { $payload = $this->readJsonPayload(); $instanceId = trim((string)( $payload['instanceId'] ?? $this->request->getParam('instanceId', '') )); if ($instanceId === '') { return new DataResponse([ 'ok' => false, 'error' => 'instanceId is required', ], 400); } $task = is_array($payload['task'] ?? null) ? $payload['task'] : null; if (!is_array($task)) { return new DataResponse([ 'ok' => false, 'error' => 'task object is required', ], 400); } try { $saved = $this->upsertInstanceOperation($instanceId, $task); } catch (\InvalidArgumentException $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 400); } return new DataResponse([ 'ok' => true, 'data' => [ 'instanceId' => $instanceId, 'task' => $saved, 'tasks' => $this->listInstanceOperations($instanceId), ], ]); } /** * @AdminRequired */ public function updateInstanceOperationStatus(): DataResponse { $payload = $this->readJsonPayload(); $instanceId = trim((string)( $payload['instanceId'] ?? $this->request->getParam('instanceId', '') )); $taskId = trim((string)( $payload['taskId'] ?? $this->request->getParam('taskId', '') )); $status = trim((string)( $payload['status'] ?? $this->request->getParam('status', '') )); $notes = trim((string)( $payload['notes'] ?? $this->request->getParam('notes', '') )); if ($instanceId === '' || $taskId === '' || $status === '') { return new DataResponse([ 'ok' => false, 'error' => 'instanceId, taskId and status are required', ], 400); } try { $saved = $this->setInstanceOperationStatus($instanceId, $taskId, $status, $notes); } catch (\InvalidArgumentException $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 400); } catch (\RuntimeException $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 404); } return new DataResponse([ 'ok' => true, 'data' => [ 'instanceId' => $instanceId, 'task' => $saved, 'tasks' => $this->listInstanceOperations($instanceId), ], ]); } /** * @AdminRequired */ public function introGrantTaskAction(): DataResponse { $payload = $this->readJsonPayload(); $instanceId = trim((string)($payload['instanceId'] ?? $this->request->getParam('instanceId', ''))); $action = strtolower(trim((string)($payload['action'] ?? $this->request->getParam('action', '')))); $task = is_array($payload['task'] ?? null) ? $payload['task'] : null; $transfer = is_array($payload['transfer'] ?? null) ? $payload['transfer'] : []; $message = trim((string)($payload['message'] ?? $this->request->getParam('message', ''))); $sendFunds = !empty($payload['sendFunds']) || $this->request->getParam('sendFunds', '') === '1'; $amountRaw = trim((string)($payload['amount'] ?? $this->request->getParam('amount', ''))); if ($instanceId === '' || $task === null) { return new DataResponse([ 'ok' => false, 'error' => 'instanceId and task are required', ], 400); } if (!in_array($action, ['approve', 'reject'], true)) { return new DataResponse([ 'ok' => false, 'error' => 'action must be approve or reject', ], 400); } $grantTask = $this->extractIntroGrantTaskData($task); if ($grantTask === []) { return new DataResponse([ 'ok' => false, 'error' => 'Task does not include intro grant request metadata', ], 400); } $amountQort = self::DEFAULT_INTRO_GRANT_AMOUNT_QORT; if ($amountRaw !== '') { if (!is_numeric($amountRaw)) { return new DataResponse([ 'ok' => false, 'error' => 'amount must be numeric', ], 400); } $amountQort = (float)$amountRaw; } elseif (is_numeric((string)($grantTask['amount'] ?? ''))) { $amountQort = (float)$grantTask['amount']; } if ($amountQort <= 0) { return new DataResponse([ 'ok' => false, 'error' => 'amount must be greater than zero', ], 400); } if ($action === 'approve' && $sendFunds && trim((string)($transfer['txSignature'] ?? '')) === '') { try { $transfer = $this->sendIntroGrantTransfer($grantTask, $amountQort); } catch (\Throwable $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 502); } } if ($action === 'approve' && $transfer !== [] && trim((string)($transfer['txSignature'] ?? '')) === '') { return new DataResponse([ 'ok' => false, 'error' => 'Grant transfer did not return a transaction signature', ], 400); } try { $connectorResponse = $this->performConnectorIntroGrantAction($instanceId, $action, $grantTask, $message, $amountQort, $transfer); } catch (\Throwable $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 502); } $task['meta'] = is_array($task['meta'] ?? null) ? $task['meta'] : []; $task['meta']['grantRequest'] = $grantTask; $task['meta']['connectorResponse'] = $connectorResponse; $task['meta']['lastGrantAction'] = [ 'action' => $action, 'message' => $message, 'amountQort' => $amountQort, 'performedAt' => gmdate('c'), 'performedBy' => $this->currentAdminUserId(), 'transfer' => $transfer, ]; $sentFunds = trim((string)($transfer['txSignature'] ?? '')) !== ''; if ($action === 'approve') { $task['status'] = 'completed'; $task['notes'] = trim( 'Approved intro grant' . ($sentFunds ? (' and sent ' . rtrim(rtrim(number_format($amountQort, 8, '.', ''), '0'), '.') . ' QORT') : '') . ($message !== '' ? (' | ' . $message) : '') ); } else { $task['status'] = 'blocked'; $task['notes'] = trim('Rejected intro grant' . ($message !== '' ? (' | ' . $message) : '')); } try { $saved = $this->upsertInstanceOperation($instanceId, $task); } catch (\Throwable $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 400); } return new DataResponse([ 'ok' => true, 'data' => [ 'instanceId' => $instanceId, 'task' => $saved, 'tasks' => $this->listInstanceOperations($instanceId), 'transfer' => $transfer, 'connector' => $connectorResponse, ], ]); } /** * @AdminRequired */ public function connectorCatalogConfig(): DataResponse { try { $data = $this->mergeCatalogConfigPriceMaps($this->fetchConnectorCatalogConfig()); $this->persistConnectorCatalogConfigCache($data); return new DataResponse([ 'ok' => true, 'data' => $data, ]); } catch (\Throwable $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 502); } } /** * @AdminRequired */ public function saveConnectorCatalogConfig(): DataResponse { $raw = $this->request->getParam('config', ''); if (is_array($raw)) { $decoded = $raw; } elseif (is_string($raw) && trim($raw) !== '') { try { $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); } catch (\Throwable $e) { return new DataResponse([ 'ok' => false, 'error' => 'Invalid config JSON', ], 400); } } else { return new DataResponse([ 'ok' => false, 'error' => 'config JSON is required', ], 400); } if (!is_array($decoded)) { return new DataResponse([ 'ok' => false, 'error' => 'config must decode to an object', ], 400); } try { $data = $this->mergeCatalogConfigPriceMaps($this->saveConnectorCatalogConfigRemote($decoded), $decoded); $this->persistConnectorCatalogConfigCache($data, $decoded); return new DataResponse([ 'ok' => true, 'data' => $data, ]); } catch (\Throwable $e) { return new DataResponse([ 'ok' => false, 'error' => $e->getMessage(), ], 502); } } /** * @AdminRequired */ public function announcements(): DataResponse { return new DataResponse([ 'ok' => true, 'data' => [ 'announcements' => $this->loadAnnouncementsFeed(), 'target' => $this->resolveAnnouncementTarget(), ], ]); } /** * @AdminRequired */ public function announcementTarget(): DataResponse { return new DataResponse([ 'ok' => true, 'data' => [ 'target' => $this->resolveAnnouncementTarget(), ], ]); } /** * @AdminRequired */ public function saveAnnouncements(): DataResponse { $raw = $this->request->getParam('announcements', ''); $decoded = []; if (is_string($raw) && trim($raw) !== '') { try { $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); } catch (\Throwable $e) { return new DataResponse([ 'error' => 'Invalid announcements JSON', ], 400); } } if (!is_array($decoded)) { return new DataResponse([ 'error' => 'announcements array is required', ], 400); } $target = $this->resolveAnnouncementTarget(); $normalized = $this->normalizeAnnouncements($decoded, $target); $this->config->setAppValue( self::APP_ID, self::KEY_ANNOUNCEMENTS_FEED, json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ); return new DataResponse([ 'ok' => true, 'data' => [ 'announcements' => $normalized, 'target' => $target, ], ]); } /** * @AdminRequired */ public function publishAnnouncement(): DataResponse { $id = trim((string)$this->request->getParam('id', '')); if ($id === '') { return new DataResponse([ 'error' => 'Announcement id is required', ], 400); } $feed = $this->loadAnnouncementsFeed(); $matchedIndex = -1; foreach ($feed as $index => $entry) { if (!is_array($entry)) { continue; } if (trim((string)($entry['id'] ?? '')) === $id) { $matchedIndex = (int)$index; break; } } if ($matchedIndex < 0 || !isset($feed[$matchedIndex]) || !is_array($feed[$matchedIndex])) { return new DataResponse([ 'error' => 'Announcement not found', ], 404); } $announcement = $feed[$matchedIndex]; $identifier = trim((string)($announcement['publishIdentifier'] ?? $announcement['identifier'] ?? '')); if ($identifier === '') { return new DataResponse([ 'error' => 'Announcement publish identifier is required', ], 400); } try { $publisherContext = $this->resolveAuthorizedPublisherContext($identifier, (int)($announcement['groupId'] ?? 0), (string)($announcement['groupName'] ?? '')); } catch (\Throwable $e) { return new DataResponse([ 'error' => $e->getMessage(), ], 400); } $publisherName = $publisherContext['publisherName']; $publisherAddress = $publisherContext['address']; $title = trim((string)($announcement['title'] ?? '')); $messageText = trim((string)($announcement['message'] ?? '')); $messageHtml = trim((string)($announcement['messageHtml'] ?? '')); if ($messageHtml === '') { $messageHtml = nl2br(htmlspecialchars($messageText, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); } $messageHtml = $this->sanitizeAnnouncementHtml($messageHtml); if ($messageHtml === '') { return new DataResponse([ 'error' => 'Announcement formatted HTML body is empty after sanitization', ], 400); } $payload = [ 'registeredName' => $publisherName, 'name' => $publisherName, 'service' => 'DOCUMENT', 'identifier' => $identifier, 'filename' => 'announcement.html', 'uploadType' => 'base64', 'data' => base64_encode($this->buildAnnouncementHtmlDocument($title, $messageHtml, $announcement)), 'title' => $title, 'description' => substr($messageText, 0, 240), ]; try { $result = $this->callBrokerQortalRequest('PUBLISH_QDN_RESOURCE', $payload); } catch (\Throwable $e) { $feed[$matchedIndex]['lastPublishStatus'] = 'error'; $feed[$matchedIndex]['lastPublishError'] = $e->getMessage(); $this->persistAnnouncementsFeed($feed); return new DataResponse([ 'error' => $e->getMessage(), ], 502); } $signature = $this->extractPublishSignature($result); $feed[$matchedIndex]['lastPublishedAt'] = gmdate('c'); $feed[$matchedIndex]['lastPublishStatus'] = 'ok'; $feed[$matchedIndex]['lastPublishError'] = ''; $feed[$matchedIndex]['lastPublishSignature'] = $signature; $feed[$matchedIndex]['messageHtml'] = $messageHtml; $this->persistAnnouncementsFeed($feed); $updated = $this->normalizeAnnouncements($feed, $this->resolveAnnouncementTarget()); $publishedAnnouncement = null; foreach ($updated as $entry) { if (!is_array($entry)) { continue; } if (trim((string)($entry['id'] ?? '')) === $id) { $publishedAnnouncement = $entry; break; } } return new DataResponse([ 'ok' => true, 'data' => [ 'announcement' => $publishedAnnouncement, 'signature' => $signature, 'identifier' => $identifier, 'publisherName' => $publisherName, 'publisherAddress' => $publisherAddress, ], ]); } /** @return array> */ private function loadAnnouncementsFeed(): array { $raw = trim($this->config->getAppValue(self::APP_ID, self::KEY_ANNOUNCEMENTS_FEED, '[]')); if ($raw === '') { return []; } try { $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); } catch (\Throwable $e) { return []; } if (!is_array($decoded)) { return []; } return $this->normalizeAnnouncements($decoded, $this->resolveAnnouncementTarget()); } /** * @param array $input * @param array|null $target * @return array> */ private function normalizeAnnouncements(array $input, ?array $target = null): array { $out = []; $targetIdentifier = trim((string)($target['identifier'] ?? '')); $targetGroupId = (int)($target['groupId'] ?? 0); $targetGroupName = trim((string)($target['groupName'] ?? '')); foreach ($input as $index => $entry) { if (!is_array($entry)) { continue; } $title = trim((string)($entry['title'] ?? '')); $message = trim((string)($entry['message'] ?? $entry['body'] ?? '')); if ($title === '' || $message === '') { continue; } $level = strtolower(trim((string)($entry['level'] ?? 'info'))); if (!in_array($level, ['info', 'success', 'warning', 'critical'], true)) { $level = 'info'; } $publishedAt = trim((string)($entry['publishedAt'] ?? '')); if ($publishedAt === '') { $publishedAt = gmdate('c'); } $id = trim((string)($entry['id'] ?? '')); if ($id === '') { $id = 'announcement_' . str_replace('.', '', (string)microtime(true)) . '_' . (string)$index; } $publishIdentifier = trim((string)($entry['publishIdentifier'] ?? $entry['identifier'] ?? '')); if ($publishIdentifier === '' && $targetIdentifier !== '') { $publishIdentifier = $targetIdentifier; } $groupId = (int)($entry['groupId'] ?? 0); if ($groupId <= 0 && $targetGroupId > 0) { $groupId = $targetGroupId; } $groupName = trim((string)($entry['groupName'] ?? '')); if ($groupName === '' && $targetGroupName !== '') { $groupName = $targetGroupName; } $messageHtml = trim((string)($entry['messageHtml'] ?? '')); if ($messageHtml === '' && $message !== '') { $messageHtml = nl2br(htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); } $messageHtml = $this->sanitizeAnnouncementHtml($messageHtml); $out[] = [ 'id' => $id, 'title' => $title, 'message' => $message, 'messageHtml' => $messageHtml, 'level' => $level, 'author' => trim((string)($entry['author'] ?? '')), 'publishedAt' => $publishedAt, 'active' => !array_key_exists('active', $entry) || !empty($entry['active']), 'publishIdentifier' => $publishIdentifier, 'groupId' => $groupId > 0 ? $groupId : null, 'groupName' => $groupName, 'lastPublishedAt' => trim((string)($entry['lastPublishedAt'] ?? '')), 'lastPublishSignature' => trim((string)($entry['lastPublishSignature'] ?? '')), 'lastPublishStatus' => trim((string)($entry['lastPublishStatus'] ?? '')), 'lastPublishError' => trim((string)($entry['lastPublishError'] ?? '')), ]; } return array_slice($out, 0, 200); } /** @return array */ private function resolveAnnouncementTarget(): array { $configuredGroupName = trim((string)$this->config->getAppValue( self::APP_ID, self::KEY_ANNOUNCEMENT_TARGET_GROUP_NAME, self::DEFAULT_ANNOUNCEMENT_TARGET_GROUP_NAME )); if ($configuredGroupName === '') { $configuredGroupName = self::DEFAULT_ANNOUNCEMENT_TARGET_GROUP_NAME; } $configuredGroupId = (int)$this->config->getAppValue( self::APP_ID, self::KEY_ANNOUNCEMENT_TARGET_GROUP_ID, '0' ); $result = [ 'configuredGroupName' => $configuredGroupName, 'configuredGroupId' => $configuredGroupId > 0 ? $configuredGroupId : null, 'matched' => false, 'groupId' => null, 'groupName' => '', 'identifier' => '', 'error' => '', ]; $lookupErrors = []; try { $matched = $this->findAnnouncementTargetInGroupList($this->listQortalGroupsViaBroker(), $configuredGroupName, $configuredGroupId); if ($matched !== null) { return $this->buildAnnouncementTargetResult($result, $matched['groupId'], $matched['groupName']); } } catch (\Throwable $e) { $lookupErrors[] = $e->getMessage(); } try { $matched = $this->findAnnouncementTargetInGroupList($this->listQortalGroupsViaNode(), $configuredGroupName, $configuredGroupId); if ($matched !== null) { return $this->buildAnnouncementTargetResult($result, $matched['groupId'], $matched['groupName']); } } catch (\Throwable $e) { $lookupErrors[] = $e->getMessage(); } if ($configuredGroupId > 0) { return $this->buildAnnouncementTargetResult($result, $configuredGroupId, $configuredGroupName); } if (!empty($lookupErrors)) { $result['error'] = implode(' | ', array_unique(array_filter(array_map('trim', $lookupErrors)))); } return $result; } /** * @param array $base * @return array */ private function buildAnnouncementTargetResult(array $base, int $groupId, string $groupName): array { $base['matched'] = $groupId > 0; $base['groupId'] = $groupId > 0 ? $groupId : null; $base['groupName'] = trim($groupName); $base['identifier'] = $groupId > 0 ? ('grp-' . (string)$groupId . '-anc') : ''; $base['error'] = ''; return $base; } /** * @param array $groups * @return array{groupId: int, groupName: string}|null */ private function findAnnouncementTargetInGroupList(array $groups, string $configuredGroupName, int $configuredGroupId): ?array { $targetKey = strtolower(trim($configuredGroupName)); foreach ($groups as $entry) { if (!is_array($entry)) { continue; } $groupId = (int)($entry['groupId'] ?? $entry['groupID'] ?? $entry['id'] ?? 0); if ($groupId <= 0) { continue; } $groupName = trim((string)($entry['groupName'] ?? $entry['name'] ?? $entry['groupname'] ?? '')); if ($configuredGroupId > 0 && $groupId === $configuredGroupId) { return [ 'groupId' => $groupId, 'groupName' => $groupName !== '' ? $groupName : $configuredGroupName, ]; } if ($targetKey === '' || $groupName === '' || strtolower($groupName) !== $targetKey) { continue; } return [ 'groupId' => $groupId, 'groupName' => $groupName, ]; } return null; } private function persistAnnouncementsFeed(array $feed): void { $this->config->setAppValue( self::APP_ID, self::KEY_ANNOUNCEMENTS_FEED, json_encode($feed, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ); } /** @return array */ private function readJsonPayload(): array { $rawPayload = ''; try { if (method_exists($this->request, 'getContent')) { $rawPayload = trim((string)$this->request->getContent()); } } catch (\Throwable) { $rawPayload = ''; } if ($rawPayload !== '') { try { $decoded = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR); if (is_array($decoded)) { return $decoded; } } catch (\Throwable) { // Fall through to legacy param parsing. } } $legacy = $this->request->getParam('payload', []); if (is_string($legacy) && trim($legacy) !== '') { try { $decodedLegacy = json_decode($legacy, true, 512, JSON_THROW_ON_ERROR); if (is_array($decodedLegacy)) { return $decodedLegacy; } } catch (\Throwable) { return []; } } return is_array($legacy) ? $legacy : []; } /** @return array>> */ private function loadInstanceOperationsState(): array { $raw = trim((string)$this->config->getAppValue(self::APP_ID, self::KEY_INSTANCE_OPERATIONS, '{}')); if ($raw === '') { return []; } try { $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); } catch (\Throwable) { return []; } if (!is_array($decoded)) { return []; } $out = []; foreach ($decoded as $instanceId => $tasksRaw) { $normalizedInstanceId = trim((string)$instanceId); if ($normalizedInstanceId === '' || !is_array($tasksRaw)) { continue; } $normalizedTasks = []; foreach ($tasksRaw as $taskRaw) { if (!is_array($taskRaw)) { continue; } try { $normalizedTasks[] = $this->sanitizeOperationTaskPayload($normalizedInstanceId, $taskRaw); } catch (\Throwable) { continue; } } if ($normalizedTasks === []) { continue; } usort($normalizedTasks, static function (array $a, array $b): int { return strcmp((string)($b['updatedAt'] ?? ''), (string)($a['updatedAt'] ?? '')); }); $out[$normalizedInstanceId] = array_slice($normalizedTasks, 0, self::INSTANCE_OPERATIONS_MAX_PER_INSTANCE); } return $out; } /** @param array>> $state */ private function saveInstanceOperationsState(array $state): void { $normalized = []; foreach ($state as $instanceId => $tasksRaw) { $normalizedInstanceId = trim((string)$instanceId); if ($normalizedInstanceId === '' || !is_array($tasksRaw)) { continue; } $normalizedTasks = []; foreach ($tasksRaw as $taskRaw) { if (!is_array($taskRaw)) { continue; } try { $normalizedTasks[] = $this->sanitizeOperationTaskPayload($normalizedInstanceId, $taskRaw); } catch (\Throwable) { continue; } } if ($normalizedTasks === []) { continue; } usort($normalizedTasks, static function (array $a, array $b): int { return strcmp((string)($b['updatedAt'] ?? ''), (string)($a['updatedAt'] ?? '')); }); $normalized[$normalizedInstanceId] = array_slice($normalizedTasks, 0, self::INSTANCE_OPERATIONS_MAX_PER_INSTANCE); } $this->config->setAppValue( self::APP_ID, self::KEY_INSTANCE_OPERATIONS, json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ); } /** @return list> */ private function listInstanceOperations(string $instanceId): array { $normalizedInstanceId = trim($instanceId); if ($normalizedInstanceId === '') { return []; } $state = $this->loadInstanceOperationsState(); $tasks = $state[$normalizedInstanceId] ?? []; if (!is_array($tasks)) { return []; } $out = []; foreach ($tasks as $task) { if (!is_array($task)) { continue; } try { $out[] = $this->sanitizeOperationTaskPayload($normalizedInstanceId, $task); } catch (\Throwable) { continue; } } usort($out, static function (array $a, array $b): int { return strcmp((string)($b['updatedAt'] ?? ''), (string)($a['updatedAt'] ?? '')); }); return array_values($out); } /** @param array $task */ /** @return array */ private function upsertInstanceOperation(string $instanceId, array $task): array { $normalizedInstanceId = trim($instanceId); if ($normalizedInstanceId === '') { throw new \InvalidArgumentException('instanceId is required'); } $state = $this->loadInstanceOperationsState(); $tasks = isset($state[$normalizedInstanceId]) && is_array($state[$normalizedInstanceId]) ? $state[$normalizedInstanceId] : []; $taskId = trim((string)($task['id'] ?? '')); $detectionKey = trim((string)($task['detectionKey'] ?? '')); $matchIndex = -1; foreach ($tasks as $index => $existingTask) { if (!is_array($existingTask)) { continue; } $existingTaskId = trim((string)($existingTask['id'] ?? '')); $existingDetectionKey = trim((string)($existingTask['detectionKey'] ?? '')); if ($taskId !== '' && $existingTaskId === $taskId) { $matchIndex = (int)$index; break; } if ($taskId === '' && $detectionKey !== '' && $existingDetectionKey !== '' && $existingDetectionKey === $detectionKey) { $matchIndex = (int)$index; break; } } $existing = $matchIndex >= 0 && isset($tasks[$matchIndex]) && is_array($tasks[$matchIndex]) ? $tasks[$matchIndex] : null; $normalizedTask = $this->sanitizeOperationTaskPayload($normalizedInstanceId, $task, $existing); if ($matchIndex >= 0) { $tasks[$matchIndex] = $normalizedTask; } else { $tasks[] = $normalizedTask; } usort($tasks, static function (array $a, array $b): int { return strcmp((string)($b['updatedAt'] ?? ''), (string)($a['updatedAt'] ?? '')); }); $state[$normalizedInstanceId] = array_slice($tasks, 0, self::INSTANCE_OPERATIONS_MAX_PER_INSTANCE); $this->saveInstanceOperationsState($state); return $normalizedTask; } /** @return array */ private function setInstanceOperationStatus(string $instanceId, string $taskId, string $status, string $notes = ''): array { $normalizedInstanceId = trim($instanceId); $normalizedTaskId = trim($taskId); if ($normalizedInstanceId === '' || $normalizedTaskId === '') { throw new \InvalidArgumentException('instanceId and taskId are required'); } $state = $this->loadInstanceOperationsState(); $tasks = isset($state[$normalizedInstanceId]) && is_array($state[$normalizedInstanceId]) ? $state[$normalizedInstanceId] : []; $matchIndex = -1; foreach ($tasks as $index => $task) { if (!is_array($task)) { continue; } if (trim((string)($task['id'] ?? '')) === $normalizedTaskId) { $matchIndex = (int)$index; break; } } if ($matchIndex < 0 || !isset($tasks[$matchIndex]) || !is_array($tasks[$matchIndex])) { throw new \RuntimeException('Task not found for this instance'); } $updatedTask = $tasks[$matchIndex]; $updatedTask['status'] = $this->normalizeOperationStatus($status); if ($notes !== '') { $updatedTask['notes'] = $notes; } $updatedTask['updatedAt'] = gmdate('c'); $updatedTask['updatedBy'] = $this->currentAdminUserId(); if ($updatedTask['status'] === 'completed') { $updatedTask['completedAt'] = gmdate('c'); } else { $updatedTask['completedAt'] = ''; } $tasks[$matchIndex] = $this->sanitizeOperationTaskPayload($normalizedInstanceId, $updatedTask, $tasks[$matchIndex]); usort($tasks, static function (array $a, array $b): int { return strcmp((string)($b['updatedAt'] ?? ''), (string)($a['updatedAt'] ?? '')); }); $state[$normalizedInstanceId] = array_slice($tasks, 0, self::INSTANCE_OPERATIONS_MAX_PER_INSTANCE); $this->saveInstanceOperationsState($state); foreach ($tasks as $taskEntry) { if (!is_array($taskEntry)) { continue; } if (trim((string)($taskEntry['id'] ?? '')) === $normalizedTaskId) { return $taskEntry; } } throw new \RuntimeException('Task was updated but could not be reloaded'); } private function normalizeOperationStatus(string $status): string { $normalized = strtolower(trim($status)); if ($normalized === '') { return 'pending'; } if ($normalized === 'done' || $normalized === 'fulfilled' || $normalized === 'complete') { $normalized = 'completed'; } if ($normalized === 'progress' || $normalized === 'started') { $normalized = 'in_progress'; } if ($normalized === 'error' || $normalized === 'failed') { $normalized = 'blocked'; } if ($normalized !== 'pending' && $normalized !== 'in_progress' && $normalized !== 'blocked' && $normalized !== 'completed') { return 'pending'; } return $normalized; } private function cleanOperationText(mixed $value, int $maxLength): string { $clean = trim((string)$value); if ($clean === '') { return ''; } if (strlen($clean) > $maxLength) { return substr($clean, 0, $maxLength); } return $clean; } private function generateOperationTaskId(): string { try { return 'op_' . bin2hex(random_bytes(8)); } catch (\Throwable) { return 'op_' . str_replace('.', '', uniqid('', true)); } } /** @param array $task * @return array */ private function extractIntroGrantTaskData(array $task): array { $meta = is_array($task['meta'] ?? null) ? $task['meta'] : []; $grant = is_array($meta['grantRequest'] ?? null) ? $meta['grantRequest'] : []; if ($grant === [] && is_array($meta['connectorResponse']['grant'] ?? null)) { $grant = $meta['connectorResponse']['grant']; } if ($grant === []) { return []; } $requestId = trim((string)($grant['requestId'] ?? $grant['id'] ?? $grant['ref'] ?? '')); $nextcloudUserId = trim((string)($grant['nextcloudUserId'] ?? $grant['userId'] ?? '')); $qortalAddress = trim((string)($grant['qortalAddress'] ?? $grant['address'] ?? '')); if ($requestId === '' && $nextcloudUserId === '' && $qortalAddress === '') { return []; } return [ 'requestId' => $requestId, 'nextcloudUserId' => $nextcloudUserId, 'displayName' => trim((string)($grant['displayName'] ?? $grant['userLabel'] ?? '')), 'paymenterCustomerId' => trim((string)($grant['paymenterCustomerId'] ?? $grant['customerId'] ?? '')), 'qortalAddress' => $qortalAddress, 'amount' => trim((string)($grant['amount'] ?? $grant['grantAmountQort'] ?? $grant['grantRequiredAmount'] ?? '')), 'message' => trim((string)($grant['message'] ?? $grant['notes'] ?? '')), 'raw' => $grant, ]; } /** @param array $task */ /** @param array|null $existing */ /** @return array */ private function sanitizeOperationTaskPayload(string $instanceId, array $task, ?array $existing = null): array { $normalizedInstanceId = trim($instanceId); if ($normalizedInstanceId === '') { throw new \InvalidArgumentException('instanceId is required'); } $title = $this->cleanOperationText($task['title'] ?? ($existing['title'] ?? ''), 240); if ($title === '') { throw new \InvalidArgumentException('Task title is required'); } $id = $this->cleanOperationText($task['id'] ?? ($existing['id'] ?? ''), 80); if ($id === '') { $id = $this->generateOperationTaskId(); } $status = $this->normalizeOperationStatus((string)($task['status'] ?? ($existing['status'] ?? 'pending'))); $source = strtolower($this->cleanOperationText($task['source'] ?? ($existing['source'] ?? 'manual'), 24)); if ($source !== 'detected') { $source = 'manual'; } $taskType = strtolower($this->cleanOperationText($task['taskType'] ?? ($existing['taskType'] ?? 'manual'), 64)); if ($taskType === '') { $taskType = 'manual'; } $createdAt = $this->cleanOperationText($task['createdAt'] ?? ($existing['createdAt'] ?? ''), 64); if ($createdAt === '') { $createdAt = gmdate('c'); } $updatedAt = gmdate('c'); $completedAt = $this->cleanOperationText($task['completedAt'] ?? ($existing['completedAt'] ?? ''), 64); if ($status === 'completed' && $completedAt === '') { $completedAt = $updatedAt; } if ($status !== 'completed') { $completedAt = ''; } $meta = []; if (is_array($task['meta'] ?? null)) { $meta = $task['meta']; } elseif (is_array($existing['meta'] ?? null)) { $meta = $existing['meta']; } return [ 'id' => $id, 'instanceId' => $normalizedInstanceId, 'source' => $source, 'detectionKey' => $this->cleanOperationText($task['detectionKey'] ?? ($existing['detectionKey'] ?? ''), 180), 'taskType' => $taskType, 'title' => $title, 'status' => $status, 'nextcloudUserId' => $this->cleanOperationText($task['nextcloudUserId'] ?? ($existing['nextcloudUserId'] ?? ''), 128), 'paymenterCustomerId' => $this->cleanOperationText($task['paymenterCustomerId'] ?? ($existing['paymenterCustomerId'] ?? ''), 64), 'userLabel' => $this->cleanOperationText($task['userLabel'] ?? ($existing['userLabel'] ?? ''), 180), 'orderRef' => $this->cleanOperationText($task['orderRef'] ?? ($existing['orderRef'] ?? ''), 128), 'productCode' => $this->cleanOperationText($task['productCode'] ?? ($existing['productCode'] ?? ''), 128), 'quantity' => $this->cleanOperationText($task['quantity'] ?? ($existing['quantity'] ?? ''), 64), 'notes' => $this->cleanOperationText($task['notes'] ?? ($existing['notes'] ?? ''), 2000), 'createdAt' => $createdAt, 'updatedAt' => $updatedAt, 'completedAt' => $completedAt, 'updatedBy' => $this->currentAdminUserId(), 'meta' => $meta, ]; } private function currentAdminUserId(): string { try { $user = $this->userSession->getUser(); if ($user !== null) { $userId = trim((string)$user->getUID()); if ($userId !== '') { return $userId; } } } catch (\Throwable) { // ignore } return 'system'; } private function getIntegrationAppValue(string $key, string $default = ''): string { return trim((string)$this->config->getAppValue(self::QORTAL_INTEGRATION_APP_ID, $key, $default)); } private function sanitizeConnectorBaseUrl(string $value): string { $normalized = rtrim(trim($value), '/'); if ($normalized === '') { return ''; } if (filter_var($normalized, FILTER_VALIDATE_URL) === false) { return ''; } $parts = parse_url($normalized); $scheme = strtolower((string)($parts['scheme'] ?? '')); if ($scheme !== 'http' && $scheme !== 'https') { return ''; } return $normalized; } /** @param array $payload * @return array */ private function normalizeConnectorRoutingConfig(array $payload): array { $profilesIn = is_array($payload['profiles'] ?? null) ? $payload['profiles'] : []; $profiles = []; foreach ([self::CONNECTOR_PROFILE_TEST, self::CONNECTOR_PROFILE_PRODUCTION] as $profileId) { $entry = is_array($profilesIn[$profileId] ?? null) ? $profilesIn[$profileId] : []; $profiles[$profileId] = [ 'baseUrl' => $this->sanitizeConnectorBaseUrl((string)($entry['baseUrl'] ?? '')), ]; } $activeProfile = trim((string)($payload['activeProfile'] ?? '')); if (!in_array($activeProfile, [self::CONNECTOR_PROFILE_TEST, self::CONNECTOR_PROFILE_PRODUCTION], true)) { $activeProfile = ''; } return [ 'version' => 1, 'activeProfile' => $activeProfile, 'profiles' => $profiles, ]; } /** @return array */ private function loadConnectorRoutingConfig(): array { $raw = trim((string)$this->config->getAppValue(self::APP_ID, self::KEY_CONNECTOR_ROUTING_CONFIG, '')); if ($raw === '') { return $this->normalizeConnectorRoutingConfig([]); } try { $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); if (!is_array($decoded)) { return $this->normalizeConnectorRoutingConfig([]); } return $this->normalizeConnectorRoutingConfig($decoded); } catch (\Throwable) { return $this->normalizeConnectorRoutingConfig([]); } } private function resolveConnectorAdminBaseUrl(): string { $config = $this->loadConnectorRoutingConfig(); $activeProfile = trim((string)($config['activeProfile'] ?? '')); $profileBaseUrl = $this->sanitizeConnectorBaseUrl((string)($config['profiles'][$activeProfile]['baseUrl'] ?? '')); if ($profileBaseUrl !== '') { return $profileBaseUrl; } return $this->sanitizeConnectorBaseUrl( $this->getIntegrationAppValue(self::QORTAL_INTEGRATION_KEY_CHD_BASE_URL, '') ); } /** @return array */ private function buildConnectorRoutingConfigResponse(): array { $config = $this->loadConnectorRoutingConfig(); $fallbackBaseUrl = $this->sanitizeConnectorBaseUrl( $this->getIntegrationAppValue(self::QORTAL_INTEGRATION_KEY_CHD_BASE_URL, '') ); return [ 'config' => $config, 'runtime' => [ 'effectiveBaseUrl' => $this->resolveConnectorAdminBaseUrl(), 'fallbackBaseUrl' => $fallbackBaseUrl, ], ]; } /** @return array */ private function fetchConnectorSummary(): array { $baseUrl = $this->resolveConnectorAdminBaseUrl(); $instanceId = $this->getIntegrationAppValue(self::QORTAL_INTEGRATION_KEY_CHD_INSTANCE_ID, ''); $instanceToken = $this->getIntegrationAppValue(self::QORTAL_INTEGRATION_KEY_CHD_INSTANCE_TOKEN, ''); $instanceSecret = $this->getIntegrationAppValue(self::QORTAL_INTEGRATION_KEY_CHD_INSTANCE_SECRET, ''); $nextcloudPublicUrl = $this->getIntegrationAppValue(self::QORTAL_INTEGRATION_KEY_NEXTCLOUD_PUBLIC_URL, ''); $syncPath = trim($this->getIntegrationAppValue(self::QORTAL_INTEGRATION_KEY_CHD_SYNC_PATH, '/api/sovereign/v1/entitlements/sync')); if ($syncPath === '') { $syncPath = '/api/sovereign/v1/entitlements/sync'; } if ($syncPath[0] !== '/') { $syncPath = '/' . $syncPath; } if ($baseUrl === '' || $instanceId === '') { throw new \RuntimeException('CHD connector is not configured yet'); } $payload = [ 'instanceId' => $instanceId, 'nextcloudPublicUrl' => $nextcloudPublicUrl, 'source' => 'chd-admin-plugin', ]; $body = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($body === false) { throw new \RuntimeException('Unable to encode connector summary request'); } $headers = [ 'Accept' => 'application/json', 'Content-Type' => 'application/json', ]; if ($instanceToken !== '') { $headers['Authorization'] = 'Bearer ' . $instanceToken; } if ($instanceSecret !== '') { $headers['X-SC-Signature'] = hash_hmac('sha256', $body, $instanceSecret); } $client = $this->clientService->newClient(); $query = http_build_query([ 'instanceId' => $instanceId, 'nextcloudPublicUrl' => $nextcloudPublicUrl, 'source' => 'chd-admin-plugin', ], '', '&', PHP_QUERY_RFC3986); $candidates = [ ['POST', '/api/sovereign/v1/admin/instance/summary'], ['GET', '/api/sovereign/v1/admin/instance/summary?' . $query], ['POST', '/api/sovereign/v1/admin/instance/snapshot'], ['GET', '/api/sovereign/v1/admin/instance/snapshot?' . $query], ['POST', $syncPath], ['GET', $syncPath . '?' . $query], ]; $errors = []; foreach ($candidates as $candidate) { $method = (string)($candidate[0] ?? 'POST'); $path = (string)($candidate[1] ?? ''); if ($path === '') { continue; } try { $decoded = $this->requestConnectorSummaryEndpoint( $client, $method, $baseUrl . $path, $headers, $body ); $summary = $decoded['summary'] ?? $decoded['data'] ?? null; if (is_array($summary)) { return $summary; } $normalized = $this->normalizeConnectorSummaryPayload($decoded, $instanceId, $nextcloudPublicUrl); if (is_array($normalized)) { return $normalized; } return []; } catch (\Throwable $e) { $errors[] = $method . ' ' . $path . ': ' . trim($e->getMessage()); } } $message = trim(implode(' | ', array_slice($errors, 0, 3))); if ($message === '') { $message = 'Connector summary request failed'; } throw new \RuntimeException($message); } /** @return array */ private function fetchConnectorInstances(int $limit, int $offset, string $search = '', bool $removed = false): array { $baseUrl = $this->resolveConnectorAdminBaseUrl(); if ($baseUrl === '') { throw new \RuntimeException('CHD connector base URL is not configured'); } $adminToken = $this->resolveConnectorAdminToken(); if ($adminToken === '') { throw new \RuntimeException('CHD connector admin API token is not configured'); } $safeLimit = max(1, min($limit, 500)); $safeOffset = max(0, $offset); $query = [ 'limit' => $safeLimit, 'offset' => $safeOffset, ]; if ($search !== '') { $query['search'] = $search; } if ($removed) { $query['removed'] = '1'; } $queryString = http_build_query($query, '', '&', PHP_QUERY_RFC3986); $url = $baseUrl . '/api/sovereign/v1/admin/instances' . ($queryString !== '' ? ('?' . $queryString) : ''); $decoded = $this->requestConnectorAdminEndpoint( 'GET', $url, $adminToken ); $instances = $decoded['instances'] ?? []; $paging = $decoded['paging'] ?? []; return [ 'instances' => is_array($instances) ? $instances : [], 'paging' => is_array($paging) ? $paging : [], ]; } /** @return array */ private function fetchConnectorInstanceDetail(string $instanceId): array { $baseUrl = $this->resolveConnectorAdminBaseUrl(); if ($baseUrl === '') { throw new \RuntimeException('CHD connector base URL is not configured'); } $adminToken = $this->resolveConnectorAdminToken(); if ($adminToken === '') { throw new \RuntimeException('CHD connector admin API token is not configured'); } $url = $baseUrl . '/api/sovereign/v1/admin/instances/' . rawurlencode($instanceId); return $this->requestConnectorAdminEndpoint( 'GET', $url, $adminToken ); } /** @return array */ private function fetchConnectorPaymenterClients(string $paymenterProfile, int $limit, int $offset, string $search): array { $baseUrl = $this->resolveConnectorAdminBaseUrl(); if ($baseUrl === '') { throw new \RuntimeException('CHD connector base URL is not configured'); } $adminToken = $this->resolveConnectorAdminToken(); if ($adminToken === '') { throw new \RuntimeException('CHD connector admin API token is not configured'); } $query = [ 'limit' => max(1, min($limit, 200)), 'offset' => max(0, $offset), ]; if ($paymenterProfile !== '') { $query['paymenterProfile'] = $paymenterProfile; } if ($search !== '') { $query['search'] = $search; } $queryString = http_build_query($query, '', '&', PHP_QUERY_RFC3986); $url = $baseUrl . '/api/sovereign/v1/admin/paymenter/clients' . ($queryString !== '' ? ('?' . $queryString) : ''); return $this->requestConnectorAdminEndpoint('GET', $url, $adminToken); } /** @return array */ private function fetchConnectorPaymenterOrders(string $paymenterProfile, int $limit, int $offset, string $status, string $customerId, string $instanceId, string $since): array { $baseUrl = $this->resolveConnectorAdminBaseUrl(); if ($baseUrl === '') { throw new \RuntimeException('CHD connector base URL is not configured'); } $adminToken = $this->resolveConnectorAdminToken(); if ($adminToken === '') { throw new \RuntimeException('CHD connector admin API token is not configured'); } $query = [ 'limit' => max(1, min($limit, 500)), 'offset' => max(0, $offset), ]; if ($paymenterProfile !== '') { $query['paymenterProfile'] = $paymenterProfile; } if ($status !== '') { $query['status'] = $status; } if ($customerId !== '') { $query['customerId'] = $customerId; } if ($instanceId !== '') { $query['instanceId'] = $instanceId; } if ($since !== '') { $query['since'] = $since; } $queryString = http_build_query($query, '', '&', PHP_QUERY_RFC3986); $url = $baseUrl . '/api/sovereign/v1/admin/paymenter/orders' . ($queryString !== '' ? ('?' . $queryString) : ''); return $this->requestConnectorAdminEndpoint('GET', $url, $adminToken); } /** @return array */ private function fetchConnectorPaymenterInvoices(string $paymenterProfile, int $limit, int $offset, string $status, string $customerId, string $instanceId, string $since, string $search): array { $baseUrl = $this->resolveConnectorAdminBaseUrl(); if ($baseUrl === '') { throw new \RuntimeException('CHD connector base URL is not configured'); } $adminToken = $this->resolveConnectorAdminToken(); if ($adminToken === '') { throw new \RuntimeException('CHD connector admin API token is not configured'); } $query = [ 'limit' => max(1, min($limit, 500)), 'offset' => max(0, $offset), ]; if ($paymenterProfile !== '') { $query['paymenterProfile'] = $paymenterProfile; } if ($status !== '') { $query['status'] = $status; } if ($customerId !== '') { $query['customerId'] = $customerId; } if ($instanceId !== '') { $query['instanceId'] = $instanceId; } if ($since !== '') { $query['since'] = $since; } if ($search !== '') { $query['search'] = $search; } $queryString = http_build_query($query, '', '&', PHP_QUERY_RFC3986); $url = $baseUrl . '/api/sovereign/v1/admin/paymenter/invoices' . ($queryString !== '' ? ('?' . $queryString) : ''); return $this->requestConnectorAdminEndpoint('GET', $url, $adminToken); } /** @return array */ private function fetchConnectorPaymenterInvoiceItems(string $paymenterProfile, int $limit, int $offset, string $search, string $referenceType, string $referenceId): array { $baseUrl = $this->resolveConnectorAdminBaseUrl(); if ($baseUrl === '') { throw new \RuntimeException('CHD connector base URL is not configured'); } $adminToken = $this->resolveConnectorAdminToken(); if ($adminToken === '') { throw new \RuntimeException('CHD connector admin API token is not configured'); } $query = [ 'limit' => max(1, min($limit, 500)), 'offset' => max(0, $offset), ]; if ($paymenterProfile !== '') { $query['paymenterProfile'] = $paymenterProfile; } if ($search !== '') { $query['search'] = $search; } if ($referenceType !== '') { $query['referenceType'] = $referenceType; } if ($referenceId !== '') { $query['referenceId'] = $referenceId; } $queryString = http_build_query($query, '', '&', PHP_QUERY_RFC3986); $url = $baseUrl . '/api/sovereign/v1/admin/paymenter/invoice-items' . ($queryString !== '' ? ('?' . $queryString) : ''); return $this->requestConnectorAdminEndpoint('GET', $url, $adminToken); } /** @param array $payload * @return array */ private function fetchConnectorPaymenterSnapshot(array $payload): array { $baseUrl = $this->resolveConnectorAdminBaseUrl(); if ($baseUrl === '') { throw new \RuntimeException('CHD connector base URL is not configured'); } $adminToken = $this->resolveConnectorAdminToken(); if ($adminToken === '') { throw new \RuntimeException('CHD connector admin API token is not configured'); } $query = []; foreach (['instanceId', 'customerId', 'requestedEmail', 'paymenterProfile'] as $key) { $value = trim((string)($payload[$key] ?? '')); if ($value !== '') { $query[$key] = $value; } } $queryString = http_build_query($query, '', '&', PHP_QUERY_RFC3986); $url = $baseUrl . '/api/sovereign/v1/admin/paymenter/snapshot' . ($queryString !== '' ? ('?' . $queryString) : ''); return $this->requestConnectorAdminEndpoint('GET', $url, $adminToken); } /** @param array $payload * @return array */ private function runConnectorPaymenterSourceSync(array $payload): array { $baseUrl = $this->resolveConnectorAdminBaseUrl(); if ($baseUrl === '') { throw new \RuntimeException('CHD connector base URL is not configured'); } $adminToken = $this->resolveConnectorAdminToken(); if ($adminToken === '') { throw new \RuntimeException('CHD connector admin API token is not configured'); } $body = []; $instanceId = trim((string)($payload['instanceId'] ?? '')); $paymenterProfile = trim((string)($payload['paymenterProfile'] ?? '')); if ($instanceId !== '') { $body['instanceId'] = $instanceId; } if ($paymenterProfile !== '') { $body['paymenterProfile'] = $paymenterProfile; } if (!empty($payload['dryRun'])) { $body['dryRun'] = true; } $url = $baseUrl . '/api/sovereign/v1/admin/paymenter/source-sync'; return $this->requestConnectorAdminEndpoint('POST', $url, $adminToken, $body); } /** @return array */ private function fetchConnectorPaymenterBackups(string $instanceId, int $limit, int $offset, string $search): array { $baseUrl = $this->resolveConnectorAdminBaseUrl(); if ($baseUrl === '') { throw new \RuntimeException('CHD connector base URL is not configured'); } $adminToken = $this->resolveConnectorAdminToken(); if ($adminToken === '') { throw new \RuntimeException('CHD connector admin API token is not configured'); } $query = [ 'limit' => max(1, min($limit, 200)), 'offset' => max(0, $offset), ]; if ($instanceId !== '') { $query['instanceId'] = $instanceId; } if ($search !== '') { $query['search'] = $search; } $queryString = http_build_query($query, '', '&', PHP_QUERY_RFC3986); $url = $baseUrl . '/api/sovereign/v1/admin/paymenter/backups' . ($queryString !== '' ? ('?' . $queryString) : ''); return $this->requestConnectorAdminEndpoint('GET', $url, $adminToken); } private function resolveConnectorAdminToken(): string { $token = trim((string)$this->config->getAppValue( self::QORTAL_INTEGRATION_APP_ID, self::QORTAL_INTEGRATION_KEY_CHD_ADMIN_API_TOKEN, '' )); if ($token !== '') { return $token; } return trim((string)$this->config->getAppValue( self::QORTAL_INTEGRATION_APP_ID, self::QORTAL_INTEGRATION_KEY_CHD_REGISTRATION_TOKEN, '' )); } /** @return array */ private function fetchConnectorCatalogConfig(): array { $baseUrl = $this->resolveConnectorAdminBaseUrl(); if ($baseUrl === '') { throw new \RuntimeException('CHD connector base URL is not configured'); } $adminToken = $this->resolveConnectorAdminToken(); if ($adminToken === '') { throw new \RuntimeException('CHD connector admin API token is not configured'); } $url = $baseUrl . '/api/sovereign/v1/admin/catalog-config'; return $this->requestConnectorAdminEndpoint('GET', $url, $adminToken); } /** * @param array $grantTask * @param array $transfer * @return array */ private function performConnectorIntroGrantAction( string $instanceId, string $action, array $grantTask, string $message, float $amountQort, array $transfer ): array { $baseUrl = $this->resolveConnectorAdminBaseUrl(); if ($baseUrl === '') { throw new \RuntimeException('CHD connector base URL is not configured'); } $adminToken = $this->resolveConnectorAdminToken(); if ($adminToken === '') { throw new \RuntimeException('CHD connector admin API token is not configured'); } $payload = [ 'requestId' => trim((string)($grantTask['requestId'] ?? '')), 'nextcloudUserId' => trim((string)($grantTask['nextcloudUserId'] ?? '')), 'paymenterCustomerId' => trim((string)($grantTask['paymenterCustomerId'] ?? '')), 'qortalAddress' => trim((string)($grantTask['qortalAddress'] ?? '')), 'amountQort' => $amountQort, 'message' => $message, 'txSignature' => trim((string)($transfer['txSignature'] ?? '')), 'source' => 'chd-admin-plugin', ]; $candidates = [ '/api/sovereign/v1/admin/instances/' . rawurlencode($instanceId) . '/grants/intro/' . $action, '/api/sovereign/v1/instances/' . rawurlencode($instanceId) . '/grants/intro/' . $action, '/instances/' . rawurlencode($instanceId) . '/grants/intro/' . $action, ]; $errors = []; foreach ($candidates as $path) { try { return $this->requestConnectorAdminEndpoint('POST', $baseUrl . $path, $adminToken, $payload); } catch (\Throwable $e) { $errors[] = trim($e->getMessage()); } } throw new \RuntimeException(implode(' | ', array_filter($errors)) ?: 'Connector intro grant action failed'); } /** @param array $grantTask * @return array */ private function sendIntroGrantTransfer(array $grantTask, float $amountQort): array { $qortalAddress = trim((string)($grantTask['qortalAddress'] ?? '')); if ($qortalAddress === '') { throw new \RuntimeException('Grant request is missing the recipient Qortal address'); } $currentAdminId = $this->currentAdminUserId(); $mapping = $this->resolveLinkedMappingForUser($currentAdminId); $walletId = trim((string)($mapping['walletId'] ?? '')); if ($walletId === '') { throw new \RuntimeException('Current CHD admin account does not have a linked wallet for sending QORT'); } $payload = [ 'coin' => 'QORT', 'walletId' => $walletId, 'recipient' => $qortalAddress, 'amount' => $amountQort, 'fee' => 0.01, ]; $result = $this->brokerClient->qortalRequest('SEND_COIN', $payload); $txSignature = $this->extractQortalSignatureFromMixed($result['data'] ?? $result); if ($txSignature === '') { $error = trim((string)($result['error'] ?? '')); if ($error === '' && $this->resultContainsApprovalRequiredMarker($result)) { $error = 'approval_required'; } if ($error === '') { $error = 'SEND_COIN did not return a transaction signature'; } throw new \RuntimeException($error); } return [ 'walletId' => $walletId, 'recipient' => $qortalAddress, 'amountQort' => $amountQort, 'txSignature' => $txSignature, 'result' => $result, ]; } private function resultContainsApprovalRequiredMarker(mixed $value): bool { if (is_string($value)) { return str_contains(strtolower($value), 'approval_required'); } if (is_array($value)) { foreach ($value as $entry) { if ($this->resultContainsApprovalRequiredMarker($entry)) { return true; } } } return false; } private function extractQortalSignatureFromMixed(mixed $value, int $depth = 0): string { if ($depth > 6 || $value === null) { return ''; } if (is_string($value)) { $trimmed = trim($value); if ($trimmed === '') { return ''; } if ((($trimmed[0] ?? '') === '{' || ($trimmed[0] ?? '') === '[') && strlen($trimmed) <= 500000) { try { $decoded = json_decode($trimmed, true, 512, JSON_THROW_ON_ERROR); return $this->extractQortalSignatureFromMixed($decoded, $depth + 1); } catch (\Throwable) { // Ignore JSON parse failures and continue. } } if ($depth === 0 && preg_match('/^[1-9A-HJ-NP-Za-km-z]{40,200}$/', $trimmed) === 1) { return $trimmed; } return ''; } if (!is_array($value)) { return ''; } $candidateKeys = ['signature', 'latestSignature', 'txSignature', 'transactionSignature']; foreach ($candidateKeys as $key) { if (!array_key_exists($key, $value)) { continue; } $direct = $this->extractQortalSignatureFromMixed($value[$key], $depth + 1); if ($direct !== '') { return $direct; } } foreach ($value as $child) { $nested = $this->extractQortalSignatureFromMixed($child, $depth + 1); if ($nested !== '') { return $nested; } } return ''; } /** @param array $configPayload */ /** @return array */ private function saveConnectorCatalogConfigRemote(array $configPayload): array { $baseUrl = $this->resolveConnectorAdminBaseUrl(); if ($baseUrl === '') { throw new \RuntimeException('CHD connector base URL is not configured'); } $adminToken = $this->resolveConnectorAdminToken(); if ($adminToken === '') { throw new \RuntimeException('CHD connector admin API token is not configured'); } $url = $baseUrl . '/api/sovereign/v1/admin/catalog-config'; return $this->requestConnectorAdminEndpoint('POST', $url, $adminToken, [ 'config' => $configPayload, ]); } /** @param array $remoteData */ /** @param array|null $submittedConfig */ private function persistConnectorCatalogConfigCache(array $remoteData, ?array $submittedConfig = null): void { $payload = $this->mergeCatalogConfigPriceMaps($remoteData, $submittedConfig); $encoded = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if (!is_string($encoded) || $encoded === '') { return; } $this->config->setAppValue(self::APP_ID, self::KEY_CONNECTOR_CATALOG_CONFIG_CACHE, $encoded); } /** @return array */ private function loadConnectorCatalogConfigCache(): array { $raw = trim((string)$this->config->getAppValue(self::APP_ID, self::KEY_CONNECTOR_CATALOG_CONFIG_CACHE, '')); if ($raw === '') { return []; } try { $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); } catch (\Throwable) { return []; } return is_array($decoded) ? $decoded : []; } /** @param array $remoteData * @param array|null $fallbackConfig * @return array */ private function mergeCatalogConfigPriceMaps(array $remoteData, ?array $fallbackConfig = null): array { $fallbackSource = $fallbackConfig; if ($fallbackSource === null) { $cached = $this->loadConnectorCatalogConfigCache(); $fallbackSource = is_array($cached['config'] ?? null) ? $cached['config'] : $cached; } if (!is_array($fallbackSource) || $fallbackSource === []) { return $remoteData; } if (is_array($remoteData['config'] ?? null)) { $remoteData['config'] = $this->mergeCatalogConfigPriceMapsIntoConfig($remoteData['config'], $fallbackSource); return $remoteData; } return $this->mergeCatalogConfigPriceMapsIntoConfig($remoteData, $fallbackSource); } /** @param array $config * @param array $fallback * @return array */ private function mergeCatalogConfigPriceMapsIntoConfig(array $config, array $fallback): array { foreach (['packagePriceMap', 'addonPriceMap', 'oneTimePriceMap'] as $key) { if (is_array($fallback[$key] ?? null) && !is_array($config[$key] ?? null)) { $config[$key] = $fallback[$key]; continue; } if (is_array($fallback[$key] ?? null) && is_array($config[$key] ?? null) && $config[$key] === []) { $config[$key] = $fallback[$key]; } } $fallbackProfiles = is_array($fallback['profileConfigs'] ?? null) ? $fallback['profileConfigs'] : []; $configProfiles = is_array($config['profileConfigs'] ?? null) ? $config['profileConfigs'] : []; foreach ($fallbackProfiles as $profileId => $profileConfig) { if (!is_string($profileId) || !is_array($profileConfig)) { continue; } if (!isset($configProfiles[$profileId]) || !is_array($configProfiles[$profileId])) { $configProfiles[$profileId] = $profileConfig; continue; } $configProfiles[$profileId] = $this->mergeCatalogConfigPriceMapsIntoConfig($configProfiles[$profileId], $profileConfig); } if ($configProfiles !== []) { $config['profileConfigs'] = $configProfiles; } return $config; } /** @param array $targetPayload * @return array */ private function saveConnectorInstanceTargetRemote(string $instanceId, array $targetPayload): array { $baseUrl = $this->resolveConnectorAdminBaseUrl(); if ($baseUrl === '') { throw new \RuntimeException('CHD connector base URL is not configured'); } $adminToken = $this->resolveConnectorAdminToken(); if ($adminToken === '') { throw new \RuntimeException('CHD connector admin API token is not configured'); } $url = $baseUrl . '/api/sovereign/v1/admin/instances/' . rawurlencode($instanceId) . '/connector-target'; return $this->requestConnectorAdminEndpoint('POST', $url, $adminToken, $targetPayload); } /** @param array $metadataPayload * @return array */ private function saveConnectorInstanceMetadataRemote(string $instanceId, array $metadataPayload): array { $baseUrl = $this->resolveConnectorAdminBaseUrl(); if ($baseUrl === '') { throw new \RuntimeException('CHD connector base URL is not configured'); } $adminToken = $this->resolveConnectorAdminToken(); if ($adminToken === '') { throw new \RuntimeException('CHD connector admin API token is not configured'); } $url = $baseUrl . '/api/sovereign/v1/admin/instances/' . rawurlencode($instanceId) . '/metadata'; return $this->requestConnectorAdminEndpoint('POST', $url, $adminToken, $metadataPayload); } /** @param array $statePayload * @return array */ private function saveConnectorInstanceStateRemote(string $instanceId, string $action, array $statePayload): array { $baseUrl = $this->resolveConnectorAdminBaseUrl(); if ($baseUrl === '') { throw new \RuntimeException('CHD connector base URL is not configured'); } $adminToken = $this->resolveConnectorAdminToken(); if ($adminToken === '') { throw new \RuntimeException('CHD connector admin API token is not configured'); } $normalizedAction = strtolower(trim($action)); if ($normalizedAction !== 'remove' && $normalizedAction !== 'restore') { throw new \RuntimeException('Unsupported instance state action'); } $url = $baseUrl . '/api/sovereign/v1/admin/instances/' . rawurlencode($instanceId) . '/' . $normalizedAction; return $this->requestConnectorAdminEndpoint('POST', $url, $adminToken, $statePayload); } /** @return array */ private function requestConnectorAdminEndpoint(string $method, string $url, string $adminToken, ?array $body = null): array { $client = $this->clientService->newClient(); $options = [ 'timeout' => 20, 'connect_timeout' => 3, 'nextcloud' => [ 'allow_local_address' => true, ], 'headers' => [ 'Accept' => 'application/json', 'Content-Type' => 'application/json', 'X-SC-Admin-Token' => $adminToken, ], ]; if ($body !== null) { $encodedBody = json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($encodedBody === false) { throw new \RuntimeException('Unable to encode request payload'); } $options['body'] = $encodedBody; } $verb = strtoupper(trim($method)); if ($verb === 'POST') { $response = $client->post($url, $options); } else { $response = $client->get($url, $options); } $statusCode = (int)$response->getStatusCode(); $rawBody = (string)$response->getBody(); try { $decoded = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR); } catch (\Throwable $e) { throw new \RuntimeException('Invalid JSON response from connector admin endpoint'); } if (!is_array($decoded)) { throw new \RuntimeException('Invalid connector admin response payload'); } if ($statusCode >= 400 || (array_key_exists('ok', $decoded) && empty($decoded['ok']))) { $message = trim((string)($decoded['error'] ?? $decoded['message'] ?? ('HTTP ' . $statusCode))); throw new \RuntimeException($message !== '' ? $message : ('HTTP ' . $statusCode)); } return $decoded; } /** @return array */ private function requestConnectorSummaryEndpoint( \OCP\Http\Client\IClient $client, string $method, string $url, array $headers, string $body ): array { $options = [ 'timeout' => 20, 'connect_timeout' => 3, 'nextcloud' => [ 'allow_local_address' => true, ], 'headers' => $headers, ]; if (strtoupper($method) === 'POST') { $options['body'] = $body; $response = $client->post($url, $options); } else { $response = $client->get($url, $options); } $statusCode = (int)$response->getStatusCode(); $rawBody = (string)$response->getBody(); try { $decoded = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR); } catch (\Throwable $e) { throw new \RuntimeException('Invalid JSON response'); } if (!is_array($decoded)) { throw new \RuntimeException('Invalid response payload'); } if ($statusCode >= 400 || (array_key_exists('ok', $decoded) && empty($decoded['ok']))) { $message = trim((string)($decoded['error'] ?? $decoded['message'] ?? ('HTTP ' . $statusCode))); throw new \RuntimeException($message !== '' ? $message : ('HTTP ' . $statusCode)); } return $decoded; } /** @return array|null */ private function normalizeConnectorSummaryPayload(array $decoded, string $instanceId, string $nextcloudPublicUrl): ?array { $payload = $decoded; if ( array_key_exists('data', $decoded) && is_array($decoded['data']) && ( array_key_exists('ok', $decoded) || array_key_exists('status', $decoded) || array_key_exists('error', $decoded) ) ) { $payload = $decoded['data']; } $hasSummaryShape = isset($payload['entitlements']) || isset($payload['source']) || isset($payload['details']) || isset($payload['verification']); if (!$hasSummaryShape) { return null; } return [ 'instanceId' => trim((string)($payload['source']['instanceId'] ?? $instanceId)), 'nextcloudPublicUrl' => trim((string)($payload['source']['nextcloudPublicUrl'] ?? $nextcloudPublicUrl)), 'entitlements' => is_array($payload['entitlements'] ?? null) ? $payload['entitlements'] : [], 'source' => is_array($payload['source'] ?? null) ? $payload['source'] : [], 'details' => is_array($payload['details'] ?? null) ? $payload['details'] : [], 'verification' => is_array($payload['verification'] ?? null) ? $payload['verification'] : [], 'fetchedAt' => gmdate('c'), ]; } private function buildAnnouncementHtmlDocument(string $title, string $messageHtml, array $entry): string { $safeTitle = htmlspecialchars($title !== '' ? $title : 'CHD Announcement', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $publishedAt = htmlspecialchars((string)($entry['publishedAt'] ?? gmdate('c')), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $author = htmlspecialchars(trim((string)($entry['author'] ?? '')), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $identifier = htmlspecialchars(trim((string)($entry['publishIdentifier'] ?? '')), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $metaParts = []; $metaParts[] = 'Published ' . $publishedAt; if ($author !== '') { $metaParts[] = 'Author ' . $author; } if ($identifier !== '') { $metaParts[] = 'Identifier ' . $identifier; } return '' . $safeTitle . '

' . $safeTitle . '

' . htmlspecialchars(implode(' | ', $metaParts), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '

' . $messageHtml . '
'; } private function sanitizeAnnouncementHtml(string $inputHtml): string { $html = trim($inputHtml); if ($html === '') { return ''; } $html = preg_replace('/<\s*(script|style|iframe|object|embed)[^>]*>.*?<\s*\/\s*\1\s*>/is', '', $html) ?? ''; $html = preg_replace('/\son[a-z]+\s*=\s*(["\']).*?\1/isu', '', $html) ?? ''; $html = preg_replace('/\son[a-z]+\s*=\s*[^\s>]+/isu', '', $html) ?? ''; $html = preg_replace('/\s(href|src)\s*=\s*(["\'])\s*javascript:[^"\']*\2/isu', '', $html) ?? ''; $allowedTags = '