Files

3018 lines
97 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\ChdAdmin\Controller;
use OCA\ChdAdmin\Service\BillingAutoFulfillmentService;
use OCA\QortalIntegration\Service\BrokerClient;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\DataResponse;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\IRequest;
use OCP\IUserSession;
class ApiController extends Controller {
private const APP_ID = 'chd_admin';
private const KEY_ANNOUNCEMENTS_FEED = 'announcements_feed';
private const KEY_INSTANCE_OPERATIONS = 'instance_operations';
private const KEY_CONNECTOR_ROUTING_CONFIG = 'connector_routing_config';
private const INSTANCE_OPERATIONS_MAX_PER_INSTANCE = 1000;
private const KEY_ANNOUNCEMENT_TARGET_GROUP_NAME = 'announcement_target_group_name';
private const KEY_ANNOUNCEMENT_TARGET_GROUP_ID = 'announcement_target_group_id';
private const KEY_CONNECTOR_CATALOG_CONFIG_CACHE = 'connector_catalog_config_cache';
private const DEFAULT_ANNOUNCEMENT_TARGET_GROUP_NAME = 'CHD-Public';
private const CONNECTOR_PROFILE_TEST = 'test';
private const CONNECTOR_PROFILE_PRODUCTION = 'production';
private const QORTAL_INTEGRATION_APP_ID = 'qortal_integration';
private const QORTAL_INTEGRATION_KEY_BROKER_BASE_URL = 'broker_base_url';
private const QORTAL_INTEGRATION_KEY_BROKER_INTERNAL_API_TOKEN = 'broker_internal_api_token';
private const QORTAL_INTEGRATION_KEY_CHD_BASE_URL = 'sc_chd_base_url';
private const QORTAL_INTEGRATION_KEY_CHD_INSTANCE_ID = 'sc_chd_instance_id';
private const QORTAL_INTEGRATION_KEY_CHD_INSTANCE_TOKEN = 'sc_chd_instance_token';
private const QORTAL_INTEGRATION_KEY_CHD_INSTANCE_SECRET = 'sc_chd_instance_secret';
private const QORTAL_INTEGRATION_KEY_NEXTCLOUD_PUBLIC_URL = 'nextcloud_public_url';
private const QORTAL_INTEGRATION_KEY_CHD_SYNC_PATH = 'sc_chd_sync_path';
private const QORTAL_INTEGRATION_KEY_CHD_ADMIN_API_TOKEN = 'sc_chd_admin_api_token';
private const QORTAL_INTEGRATION_KEY_CHD_REGISTRATION_TOKEN = 'sc_chd_registration_token';
private const DEFAULT_INTRO_GRANT_AMOUNT_QORT = 2.0;
public function __construct(
string $appName,
IRequest $request,
private IConfig $config,
private IClientService $clientService,
private IUserSession $userSession,
private BrokerClient $brokerClient,
private BillingAutoFulfillmentService $billingAutoFulfillmentService,
) {
parent::__construct($appName, $request);
}
/**
* @AdminRequired
*/
public function overview(): DataResponse {
$appId = 'qortal_integration';
$get = fn (string $key, string $default = ''): string
=> 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<int, array<string, mixed>> */
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<int, mixed> $input
* @param array<string, mixed>|null $target
* @return array<int, array<string, mixed>>
*/
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<string, mixed> */
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<string, mixed> $base
* @return array<string, mixed>
*/
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<int, mixed> $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<string, mixed> */
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<string, array<int, array<string, mixed>>> */
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<string, array<int, array<string, mixed>>> $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<array<string, mixed>> */
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<string, mixed> $task */
/** @return array<string, mixed> */
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<string, mixed> */
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<string, mixed> $task
* @return array<string, mixed>
*/
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<string, mixed> $task */
/** @param array<string, mixed>|null $existing */
/** @return array<string, mixed> */
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<string, mixed> $payload
* @return array<string, mixed>
*/
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<string, mixed> */
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<string, mixed> */
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<string, mixed> */
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<string, mixed> */
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<string, mixed> */
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<string, mixed> */
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<string, mixed> */
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<string, mixed> */
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<string, mixed> */
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<string, mixed> $payload
* @return array<string, mixed>
*/
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<string, mixed> $payload
* @return array<string, mixed>
*/
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<string, mixed> */
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<string, mixed> */
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<string, mixed> $grantTask
* @param array<string, mixed> $transfer
* @return array<string, mixed>
*/
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<string, mixed> $grantTask
* @return array<string, mixed>
*/
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<string, mixed> $configPayload */
/** @return array<string, mixed> */
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<string, mixed> $remoteData */
/** @param array<string, mixed>|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<string, mixed> */
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<string, mixed> $remoteData
* @param array<string, mixed>|null $fallbackConfig
* @return array<string, mixed>
*/
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<string, mixed> $config
* @param array<string, mixed> $fallback
* @return array<string, mixed>
*/
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<string, mixed> $targetPayload
* @return array<string, mixed>
*/
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<string, mixed> $metadataPayload
* @return array<string, mixed>
*/
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<string, mixed> $statePayload
* @return array<string, mixed>
*/
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<string, mixed> */
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<string, mixed> */
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<string, mixed>|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 '<!doctype html><html><head><meta charset="utf-8"><title>'
. $safeTitle
. '</title></head><body><article><h1>'
. $safeTitle
. '</h1><p>'
. htmlspecialchars(implode(' | ', $metaParts), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '</p><section>'
. $messageHtml
. '</section></article></body></html>';
}
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 = '<p><br><strong><b><em><i><u><ul><ol><li><blockquote><pre><code><h1><h2><h3><h4><h5><h6><a><span><div>';
$html = strip_tags($html, $allowedTags);
return trim($html);
}
/** @return array{publisherName: string, address: string} */
private function resolveAuthorizedPublisherContext(string $identifier, int $groupIdHint, string $groupNameHint): array {
$user = $this->userSession->getUser();
if ($user === null) {
throw new \RuntimeException('Authentication required');
}
$userId = trim((string)$user->getUID());
if ($userId === '') {
throw new \RuntimeException('Authenticated user id is empty');
}
$target = $this->resolveAnnouncementTarget();
$targetIdentifier = trim((string)($target['identifier'] ?? ''));
if ($targetIdentifier === '' || strcasecmp($targetIdentifier, $identifier) !== 0) {
throw new \RuntimeException('Announcement identifier does not match the configured target group announcement identifier');
}
$groupId = (int)($target['groupId'] ?? 0);
if ($groupId <= 0 && $groupIdHint > 0) {
$groupId = $groupIdHint;
}
if ($groupId <= 0) {
throw new \RuntimeException('Target announcement group is not resolved yet');
}
$groupName = trim((string)($target['groupName'] ?? ''));
if ($groupName === '') {
$groupName = trim($groupNameHint);
}
$address = $this->resolveLinkedAddressForUser($userId);
if ($address === '') {
throw new \RuntimeException('Current admin user does not have a linked Qortal address');
}
$memberGroups = $this->listMemberGroupsForAddress($address);
$isGroupAdmin = false;
foreach ($memberGroups as $entry) {
if (!is_array($entry)) {
continue;
}
$entryGroupId = (int)($entry['groupId'] ?? 0);
$entryGroupName = trim((string)($entry['groupName'] ?? ''));
$idMatch = $entryGroupId > 0 && $entryGroupId === $groupId;
$nameMatch = $groupName !== '' && $entryGroupName !== '' && strcasecmp($entryGroupName, $groupName) === 0;
if (!$idMatch && !$nameMatch) {
continue;
}
$isGroupAdmin = !empty($entry['isAdmin']);
break;
}
if (!$isGroupAdmin) {
throw new \RuntimeException('Linked account is not an admin of the announcements group and cannot publish');
}
$publisherName = $this->resolvePrimaryNameByAddress($address);
if ($publisherName === '') {
throw new \RuntimeException('Linked account has no registered primary name for publishing');
}
return [
'publisherName' => $publisherName,
'address' => $address,
];
}
private function extractNameFromValue(mixed $value, int $depth = 0): string {
if ($depth > 6 || $value === null) {
return '';
}
if (is_string($value)) {
return trim($value);
}
if (!is_array($value)) {
return '';
}
foreach (['name', 'primaryName', 'registeredName', 'registered_name'] as $key) {
if (isset($value[$key]) && is_scalar($value[$key])) {
$candidate = trim((string)$value[$key]);
if ($candidate !== '') {
return $candidate;
}
}
}
if (array_key_exists('data', $value)) {
$nested = $this->extractNameFromValue($value['data'], $depth + 1);
if ($nested !== '') {
return $nested;
}
}
if (array_key_exists('names', $value)) {
$nestedNames = $this->extractNameFromValue($value['names'], $depth + 1);
if ($nestedNames !== '') {
return $nestedNames;
}
}
foreach ($value as $entry) {
$nested = $this->extractNameFromValue($entry, $depth + 1);
if ($nested !== '') {
return $nested;
}
}
return '';
}
private function resolvePrimaryNameByAddress(string $address): string {
$normalizedAddress = trim($address);
if ($normalizedAddress === '') {
return '';
}
try {
$primaryResult = $this->callBrokerQortalRequest('GET_PRIMARY_NAME', ['address' => $normalizedAddress]);
$primaryName = $this->extractNameFromValue($primaryResult['data'] ?? null);
if ($primaryName !== '') {
return $primaryName;
}
$namesResult = $this->callBrokerQortalRequest('GET_ACCOUNT_NAMES', ['address' => $normalizedAddress]);
return $this->extractNameFromValue($namesResult['data'] ?? null);
} catch (\Throwable $e) {
return '';
}
}
private function extractPublishSignature(array $result): string {
$candidates = [
$result['data']['signature'] ?? null,
$result['data']['data']['signature'] ?? null,
$result['data']['result']['signature'] ?? null,
$result['data']['signature58'] ?? null,
$result['data']['data']['signature58'] ?? null,
];
foreach ($candidates as $candidate) {
if (is_string($candidate) && trim($candidate) !== '') {
return trim($candidate);
}
}
return '';
}
/** @param array<string, mixed> $payload */
/** @return array<string, mixed> */
private function callBrokerQortalRequest(string $requestType, array $payload): array {
$response = $this->requestBrokerJson('/api/qortal/request', [
'requestType' => $requestType,
'payload' => $payload,
]);
if (empty($response['ok'])) {
$message = trim((string)($response['error'] ?? ($requestType . ' failed')));
throw new \RuntimeException($message !== '' ? $message : ($requestType . ' failed'));
}
return $response;
}
/** @return array<string, mixed> */
private function requestBrokerJson(string $path, array $jsonBody): array {
$brokerBaseUrl = trim((string)$this->config->getAppValue(
self::QORTAL_INTEGRATION_APP_ID,
self::QORTAL_INTEGRATION_KEY_BROKER_BASE_URL,
'http://broker:3000'
));
if ($brokerBaseUrl === '') {
throw new \RuntimeException('Broker base URL is not configured');
}
$brokerBaseUrl = rtrim($brokerBaseUrl, '/');
$internalToken = trim((string)$this->config->getAppValue(
self::QORTAL_INTEGRATION_APP_ID,
self::QORTAL_INTEGRATION_KEY_BROKER_INTERNAL_API_TOKEN,
''
));
if ($internalToken === '') {
$envToken = getenv('QORTAL_BROKER_INTERNAL_API_TOKEN');
if (is_string($envToken) && trim($envToken) !== '') {
$internalToken = trim($envToken);
}
}
$headers = [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
];
if ($internalToken !== '') {
$headers['X-Broker-Internal-Token'] = $internalToken;
}
$client = $this->clientService->newClient();
$response = $client->post($brokerBaseUrl . '/' . ltrim($path, '/'), [
'timeout' => 45,
'connect_timeout' => 3,
'nextcloud' => [
'allow_local_address' => true,
],
'headers' => $headers,
'body' => json_encode($jsonBody),
]);
$body = (string)$response->getBody();
try {
$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable $e) {
throw new \RuntimeException('Invalid JSON from broker request for ' . $path);
}
if (!is_array($decoded)) {
throw new \RuntimeException('Invalid broker response payload for ' . $path);
}
return $decoded;
}
/** @return array<string, mixed> */
private function requestBrokerGetJson(string $path): array {
$brokerBaseUrl = trim((string)$this->config->getAppValue(
self::QORTAL_INTEGRATION_APP_ID,
self::QORTAL_INTEGRATION_KEY_BROKER_BASE_URL,
'http://broker:3000'
));
if ($brokerBaseUrl === '') {
throw new \RuntimeException('Broker base URL is not configured');
}
$brokerBaseUrl = rtrim($brokerBaseUrl, '/');
$internalToken = trim((string)$this->config->getAppValue(
self::QORTAL_INTEGRATION_APP_ID,
self::QORTAL_INTEGRATION_KEY_BROKER_INTERNAL_API_TOKEN,
''
));
if ($internalToken === '') {
$envToken = getenv('QORTAL_BROKER_INTERNAL_API_TOKEN');
if (is_string($envToken) && trim($envToken) !== '') {
$internalToken = trim($envToken);
}
}
$headers = [
'Accept' => 'application/json',
];
if ($internalToken !== '') {
$headers['X-Broker-Internal-Token'] = $internalToken;
}
$client = $this->clientService->newClient();
$response = $client->get($brokerBaseUrl . '/' . ltrim($path, '/'), [
'timeout' => 20,
'connect_timeout' => 3,
'nextcloud' => [
'allow_local_address' => true,
],
'headers' => $headers,
]);
$body = (string)$response->getBody();
try {
$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable $e) {
throw new \RuntimeException('Invalid JSON from broker request for ' . $path);
}
if (!is_array($decoded)) {
throw new \RuntimeException('Invalid broker response payload for ' . $path);
}
return $decoded;
}
private function resolveLinkedAddressForUser(string $userId): string {
$mapping = $this->resolveLinkedMappingForUser($userId);
return trim((string)($mapping['qortalAddress'] ?? $mapping['address'] ?? ''));
}
/** @return array<string, mixed> */
private function resolveLinkedMappingForUser(string $userId): array {
$userId = trim($userId);
if ($userId === '') {
return [];
}
try {
$response = $this->brokerClient->listMappingsByNextcloudUser($userId, 500);
if (!is_array($response)) {
return [];
}
$preferred = $this->extractPreferredMappingFromPayload($response);
return is_array($preferred) ? $preferred : [];
} catch (\Throwable) {
return [];
}
}
/** @return array<string, mixed>|null */
private function extractPreferredMappingFromPayload(array $mappingPayload): ?array {
$mappings = [];
if (isset($mappingPayload['data']['mappings']) && is_array($mappingPayload['data']['mappings'])) {
$mappings = $mappingPayload['data']['mappings'];
} elseif (isset($mappingPayload['mappings']) && is_array($mappingPayload['mappings'])) {
$mappings = $mappingPayload['mappings'];
}
$fallback = null;
foreach ($mappings as $entry) {
if (!is_array($entry)) {
continue;
}
if (!empty($entry['walletId']) && ($entry['status'] ?? '') === 'linked') {
return $entry;
}
if ($fallback === null && !empty($entry['walletId'])) {
$fallback = $entry;
}
if ($fallback === null && !empty($entry['qortalAddress'])) {
$fallback = $entry;
}
}
return is_array($fallback) ? $fallback : null;
}
/** @return array<int, array<string, mixed>> */
private function listMemberGroupsForAddress(string $address): array {
$nodeUrl = trim((string)$this->config->getAppValue(
self::QORTAL_INTEGRATION_APP_ID,
'qortal_node_url',
''
));
if ($nodeUrl === '') {
$nodeUrl = trim((string)$this->config->getAppValue(
self::QORTAL_INTEGRATION_APP_ID,
'external_auth_node_url',
''
));
}
if ($nodeUrl === '') {
throw new \RuntimeException('Qortal node URL is not configured for group membership validation');
}
$apiKey = trim((string)$this->config->getAppValue(
self::QORTAL_INTEGRATION_APP_ID,
'qortal_node_api_key',
''
));
if ($apiKey === '') {
$apiKey = trim((string)$this->config->getAppValue(
self::QORTAL_INTEGRATION_APP_ID,
'external_auth_node_api_key',
''
));
}
$headers = [
'Accept' => 'application/json',
];
if ($apiKey !== '') {
$headers['X-API-KEY'] = $apiKey;
}
$client = $this->clientService->newClient();
$response = $client->get(rtrim($nodeUrl, '/') . '/groups/member/' . rawurlencode($address), [
'timeout' => 20,
'connect_timeout' => 3,
'nextcloud' => [
'allow_local_address' => true,
],
'headers' => $headers,
]);
$body = (string)$response->getBody();
try {
$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable $e) {
throw new \RuntimeException('Invalid groups/member response from Qortal node');
}
if (!is_array($decoded)) {
return [];
}
$out = [];
foreach ($decoded as $entry) {
if (!is_array($entry)) {
continue;
}
$groupId = (int)($entry['groupId'] ?? 0);
if ($groupId <= 0) {
continue;
}
$out[] = [
'groupId' => $groupId,
'groupName' => trim((string)($entry['groupName'] ?? '')),
'isAdmin' => !empty($entry['isAdmin']),
];
}
return $out;
}
/** @return array<int, mixed> */
private function listQortalGroupsViaBroker(): array {
$decoded = $this->callBrokerQortalRequest('LIST_GROUPS', [
'limit' => 0,
'offset' => 0,
'reverse' => false,
]);
$data = $decoded['data'] ?? null;
if (is_array($data) && isset($data['groups']) && is_array($data['groups'])) {
return $data['groups'];
}
if (is_array($data) && isset($data['result']) && is_array($data['result'])) {
return $data['result'];
}
return is_array($data) ? $data : [];
}
/** @return array<int, mixed> */
private function listQortalGroupsViaNode(): array {
$nodeUrl = trim((string)$this->config->getAppValue(
self::QORTAL_INTEGRATION_APP_ID,
'qortal_node_url',
''
));
if ($nodeUrl === '') {
$nodeUrl = trim((string)$this->config->getAppValue(
self::QORTAL_INTEGRATION_APP_ID,
'external_auth_node_url',
''
));
}
if ($nodeUrl === '') {
throw new \RuntimeException('Qortal node URL is not configured for groups lookup');
}
$apiKey = trim((string)$this->config->getAppValue(
self::QORTAL_INTEGRATION_APP_ID,
'qortal_node_api_key',
''
));
if ($apiKey === '') {
$apiKey = trim((string)$this->config->getAppValue(
self::QORTAL_INTEGRATION_APP_ID,
'external_auth_node_api_key',
''
));
}
$headers = [
'Accept' => 'application/json',
];
if ($apiKey !== '') {
$headers['X-API-KEY'] = $apiKey;
}
$client = $this->clientService->newClient();
$response = $client->get(rtrim($nodeUrl, '/') . '/groups?limit=0&offset=0&reverse=false', [
'timeout' => 20,
'connect_timeout' => 3,
'nextcloud' => [
'allow_local_address' => true,
],
'headers' => $headers,
]);
$body = (string)$response->getBody();
try {
$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable $e) {
throw new \RuntimeException('Invalid groups response from Qortal node');
}
if (!is_array($decoded)) {
throw new \RuntimeException('Invalid groups response payload from Qortal node');
}
if (isset($decoded['groups']) && is_array($decoded['groups'])) {
return $decoded['groups'];
}
if (isset($decoded['result']) && is_array($decoded['result'])) {
return $decoded['result'];
}
return $decoded;
}
}