3018 lines
97 KiB
PHP
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;
|
|
}
|
|
}
|