'nuqloud_team_starter', 'replication_basic' => 'nuqloud_starter', 'replication_standard' => 'nuqloud_advanced', 'replication_enterprise' => 'nuqloud_pro', ]; private const ALLOWED_PACKAGE_TIERS = [ 'nuqloud_starter', 'nuqloud_advanced', 'nuqloud_pro', 'nuqloud_team_starter', 'nuqloud_team_advanced', 'nuqloud_team_pro', 'nuqloud_branded', 'nuqloud_branded_pro', 'nuqloud_branded_enterprise', ]; public function __construct( private IConfig $config, private IClientService $clientService, private BrokerClient $brokerClient, private IntegrationConfig $integrationConfig, private NotificationService $notificationService, private IUserManager $userManager, ) { } /** @return array */ public function getOverview(): array { $state = $this->loadState(); return $this->buildOverview($state); } /** @return array */ public function saveEnabled(bool $enabled): array { $this->config->setAppValue(self::APP_ID, self::ENABLED_KEY, $enabled ? '1' : '0'); return $this->getOverview(); } /** @return array */ public function runCycle(bool $force = false): array { return $this->runFulfillmentCycle($force, '', '', true); } /** @return array */ public function runBackfill(string $since, string $invoiceId): array { $since = trim($since); if ($since !== '' && strtotime($since) === false) { return [ 'ok' => false, 'status' => 'error', 'message' => 'Invalid since date.', 'overview' => $this->getOverview(), ]; } $invoiceId = $this->sanitizeReplayId($invoiceId); if ($since === '' && $invoiceId === '') { return [ 'ok' => false, 'status' => 'error', 'message' => 'Backfill requires a since date or invoice ID.', 'overview' => $this->getOverview(), ]; } return $this->runFulfillmentCycle(true, $since, $invoiceId, false); } /** @return array */ private function runFulfillmentCycle(bool $force, string $sinceOverride, string $invoiceId, bool $updateWatermark): array { $startedAt = gmdate('c'); $state = $this->loadState(); $enabled = $this->isEnabled(); if (!$enabled && !$force) { $this->updateRunState($state, [ 'lastRunStartedAt' => $startedAt, 'lastRunCompletedAt' => gmdate('c'), 'lastRunStatus' => 'skipped', 'lastRunMessage' => 'Automatic invite fulfillment is disabled.', 'lastRunCount' => 0, 'lastRunFulfilledCount' => 0, 'lastRunSkippedCount' => 0, 'lastRunErrorCount' => 0, ]); $this->saveState($state); return [ 'ok' => true, 'status' => 'skipped', 'startedAt' => $startedAt, 'completedAt' => gmdate('c'), 'processed' => 0, 'fulfilled' => 0, 'skipped' => 0, 'errors' => 0, 'message' => 'Automatic invite fulfillment is disabled.', 'overview' => $this->buildOverview($state), ]; } try { $since = $sinceOverride !== '' ? gmdate('c', (int)strtotime($sinceOverride)) : ($invoiceId !== '' ? '' : $this->resolveLookbackSince($state)); $orders = $this->fetchConnectorOrders($since, $invoiceId, $sinceOverride !== '' || $invoiceId !== ''); $summary = [ 'ok' => true, 'status' => 'success', 'startedAt' => $startedAt, 'completedAt' => '', 'processed' => 0, 'fulfilled' => 0, 'skipped' => 0, 'errors' => 0, 'message' => '', ]; foreach ($orders as $order) { if (!is_array($order)) { continue; } if (!$this->isFulfillmentEligibleStatus($order)) { continue; } $summary['processed']++; $result = $this->processEligibleOrder($order, $state); $status = (string)($result['status'] ?? 'skipped'); if ($status === 'fulfilled') { $summary['fulfilled']++; } elseif ($status === 'error') { $summary['errors']++; } else { $summary['skipped']++; } } $message = $this->buildRunMessage($summary); $this->updateRunState($state, [ 'lastRunStartedAt' => $startedAt, 'lastRunCompletedAt' => gmdate('c'), 'lastRunStatus' => ((int)$summary['errors'] > 0) ? 'error' : 'success', 'lastRunMessage' => $message, 'lastRunCount' => (int)$summary['processed'], 'lastRunFulfilledCount' => (int)$summary['fulfilled'], 'lastRunSkippedCount' => (int)$summary['skipped'], 'lastRunErrorCount' => (int)$summary['errors'], 'lastRunSince' => $since, ]); if ($updateWatermark) { $this->updateRunState($state, [ 'lastSuccessfulRunCompletedAt' => gmdate('c'), ]); } $this->pruneState($state); $this->saveState($state); $summary['completedAt'] = gmdate('c'); $summary['message'] = $message; $summary['overview'] = $this->buildOverview($state); return $summary; } catch (\Throwable $e) { $this->updateRunState($state, [ 'lastRunStartedAt' => $startedAt, 'lastRunCompletedAt' => gmdate('c'), 'lastRunStatus' => 'error', 'lastRunMessage' => $e->getMessage(), ]); $this->saveState($state); return [ 'ok' => false, 'status' => 'error', 'startedAt' => $startedAt, 'completedAt' => gmdate('c'), 'processed' => 0, 'fulfilled' => 0, 'skipped' => 0, 'errors' => 1, 'message' => $e->getMessage(), 'overview' => $this->buildOverview($state), ]; } } /** @return array */ private function processEligibleOrder(array $order, array &$state): array { $orderKey = $this->resolveOrderKey($order); if ($orderKey === '') { return ['status' => 'skipped', 'reason' => 'missing_order_id']; } $entry = $this->getEntry($state, $orderKey); $sourceUpdatedAt = $this->resolveOrderUpdatedAt($order); if ($this->isEntryFulfilled($entry)) { return ['status' => 'skipped', 'reason' => 'already_fulfilled']; } if ($this->shouldDelayRetry($entry, $sourceUpdatedAt)) { return ['status' => 'skipped', 'reason' => 'retry_later']; } $packageTier = $this->resolvePackageTier($order); if ($packageTier === '' || !$this->isAllowedPackageTier($packageTier)) { return $this->recordSkip($state, $orderKey, $entry, $order, $sourceUpdatedAt, 'unsupported_package_tier', self::RETRY_AFTER_DATA_ISSUE_SECONDS); } if (!$this->hasCurrentMonthlyPayment($order)) { return $this->recordSkip($state, $orderKey, $entry, $order, $sourceUpdatedAt, 'stale_monthly_invoice', self::RETRY_AFTER_DATA_ISSUE_SECONDS); } $recipientEmail = $this->resolveRecipientEmail($order); if ($recipientEmail === '') { return $this->recordSkip($state, $orderKey, $entry, $order, $sourceUpdatedAt, 'missing_recipient_email', self::RETRY_AFTER_DATA_ISSUE_SECONDS); } if ($this->isExistingNextcloudUser($recipientEmail)) { return $this->recordSkip($state, $orderKey, $entry, $order, $sourceUpdatedAt, 'existing_nextcloud_user', self::RETRY_AFTER_DATA_ISSUE_SECONDS); } $displayName = $this->resolveRecipientDisplayName($order, $recipientEmail); $inviteToken = trim((string)($entry['inviteToken'] ?? '')); if ($inviteToken === '') { try { $inviteHours = $this->resolveInviteTtlHours(); $inviteResponse = $this->brokerClient->createInvite( $inviteHours, 'chd_admin_auto_fulfillment', $this->buildInviteComment($orderKey, $order, $recipientEmail, $packageTier) ); $inviteToken = $this->extractInviteToken($inviteResponse); if ($inviteToken === '') { throw new \RuntimeException('Invite token was not returned by the broker'); } $entry['inviteToken'] = $inviteToken; $entry['inviteCreatedAt'] = gmdate('c'); $entry['inviteResponseStatus'] = (string)($inviteResponse['status'] ?? ''); } catch (\Throwable $e) { return $this->recordError($state, $orderKey, $entry, $order, $sourceUpdatedAt, 'invite_creation_failed: ' . $e->getMessage(), self::RETRY_AFTER_INVITE_FAILURE_SECONDS); } } $emailResult = $this->notificationService->sendInviteEmailToAddress( $recipientEmail, $displayName, $inviteToken, [ 'orderId' => $orderKey, 'packageTier' => $packageTier, 'paymentStatus' => (string)($order['paymentStatus'] ?? ''), 'instanceId' => (string)($order['instanceId'] ?? ''), 'customerName' => (string)($order['customerName'] ?? ''), 'customerEmail' => $recipientEmail, ] ); if (empty($emailResult['sent'])) { $error = trim((string)($emailResult['error'] ?? 'email_send_failed')); return $this->recordError($state, $orderKey, $entry, $order, $sourceUpdatedAt, 'email_send_failed: ' . $error, self::RETRY_AFTER_EMAIL_FAILURE_SECONDS); } $entry['orderId'] = $orderKey; $entry['invoiceId'] = (string)($order['invoiceId'] ?? ''); $entry['instanceId'] = (string)($order['instanceId'] ?? ''); $entry['packageTier'] = $packageTier; $entry['customerEmail'] = $recipientEmail; $entry['displayName'] = $displayName; $entry['paymentStatus'] = (string)($order['paymentStatus'] ?? ''); $entry['lastSourceUpdatedAt'] = $sourceUpdatedAt; $entry['inviteToken'] = $inviteToken; $entry['emailSentAt'] = gmdate('c'); $entry['fulfilledAt'] = gmdate('c'); $entry['retryAfterAt'] = ''; $entry['lastError'] = ''; $entry['lastStatus'] = 'fulfilled'; $entry['lastAttemptAt'] = gmdate('c'); $this->setEntry($state, $orderKey, $entry); $this->pruneState($state); $this->saveState($state); return [ 'status' => 'fulfilled', 'orderId' => $orderKey, 'inviteToken' => $inviteToken, ]; } /** @return array */ private function recordSkip(array &$state, string $orderKey, array $entry, array $order, string $sourceUpdatedAt, string $reason, int $retryAfterSeconds): array { $entry['orderId'] = $orderKey; $entry['invoiceId'] = (string)($order['invoiceId'] ?? ''); $entry['instanceId'] = (string)($order['instanceId'] ?? ''); $entry['packageTier'] = $this->resolvePackageTier($order); $entry['customerEmail'] = $this->resolveRecipientEmail($order); $entry['displayName'] = $this->resolveRecipientDisplayName($order, $entry['customerEmail'] ?? ''); $entry['paymentStatus'] = (string)($order['paymentStatus'] ?? ''); $entry['lastSourceUpdatedAt'] = $sourceUpdatedAt; $entry['lastAttemptAt'] = gmdate('c'); $entry['lastStatus'] = 'skipped'; $entry['lastError'] = $reason; $entry['retryAfterAt'] = gmdate('c', time() + $retryAfterSeconds); $this->setEntry($state, $orderKey, $entry); $this->pruneState($state); $this->saveState($state); return [ 'status' => 'skipped', 'orderId' => $orderKey, 'reason' => $reason, ]; } /** @return array */ private function recordError(array &$state, string $orderKey, array $entry, array $order, string $sourceUpdatedAt, string $reason, int $retryAfterSeconds): array { $entry['orderId'] = $orderKey; $entry['invoiceId'] = (string)($order['invoiceId'] ?? ''); $entry['instanceId'] = (string)($order['instanceId'] ?? ''); $entry['packageTier'] = $this->resolvePackageTier($order); $entry['customerEmail'] = $this->resolveRecipientEmail($order); $entry['displayName'] = $this->resolveRecipientDisplayName($order, $entry['customerEmail'] ?? ''); $entry['paymentStatus'] = (string)($order['paymentStatus'] ?? ''); $entry['lastSourceUpdatedAt'] = $sourceUpdatedAt; $entry['lastAttemptAt'] = gmdate('c'); $entry['lastStatus'] = 'error'; $entry['lastError'] = $reason; $entry['retryAfterAt'] = gmdate('c', time() + $retryAfterSeconds); $this->setEntry($state, $orderKey, $entry); $this->pruneState($state); $this->saveState($state); return [ 'status' => 'error', 'orderId' => $orderKey, 'reason' => $reason, ]; } /** @return array */ private function getEntry(array $state, string $orderKey): array { $processed = is_array($state['processedOrders'] ?? null) ? $state['processedOrders'] : []; $entry = $processed[$orderKey] ?? []; return is_array($entry) ? $entry : []; } /** @param array $entry */ private function setEntry(array &$state, string $orderKey, array $entry): void { $processed = is_array($state['processedOrders'] ?? null) ? $state['processedOrders'] : []; $processed[$orderKey] = $entry; $state['processedOrders'] = $processed; } private function isEntryFulfilled(array $entry): bool { return trim((string)($entry['fulfilledAt'] ?? '')) !== ''; } private function shouldDelayRetry(array $entry, string $sourceUpdatedAt): bool { $retryAfterAt = trim((string)($entry['retryAfterAt'] ?? '')); if ($retryAfterAt === '') { return false; } $entrySourceUpdatedAt = trim((string)($entry['lastSourceUpdatedAt'] ?? '')); if ($entrySourceUpdatedAt !== '' && $entrySourceUpdatedAt !== $sourceUpdatedAt) { return false; } $retryTs = strtotime($retryAfterAt); return $retryTs !== false && $retryTs > time(); } private function resolveLookbackSince(array $state): string { $lastRunAt = trim((string)($state['lastSuccessfulRunCompletedAt'] ?? '')); if ($lastRunAt === '') { return gmdate('c', max(0, time() - self::DEFAULT_LOOKBACK_SECONDS)); } $lastTs = strtotime($lastRunAt); if ($lastTs === false || $lastTs <= 0) { return gmdate('c', max(0, time() - self::DEFAULT_LOOKBACK_SECONDS)); } return gmdate('c', max(0, $lastTs - self::RETRY_AFTER_EMAIL_FAILURE_SECONDS)); } /** * @return array> */ private function fetchConnectorOrders(string $since, string $invoiceId = '', bool $wideScan = 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'); } $query = [ 'limit' => self::PAGE_LIMIT, 'offset' => 0, 'maxPages' => $wideScan ? 50 : self::MAX_PAGES, ]; if ($since !== '') { $query['since'] = $since; } if ($invoiceId !== '') { $query['search'] = $invoiceId; } $queryString = http_build_query($query, '', '&', PHP_QUERY_RFC3986); $url = $baseUrl . '/api/sovereign/v1/admin/paymenter/orders' . ($queryString !== '' ? ('?' . $queryString) : ''); $response = $this->requestConnectorAdminEndpoint('GET', $url, $adminToken); $orders = is_array($response['orders'] ?? null) ? $response['orders'] : []; if ($invoiceId === '') { return $orders; } return array_values(array_filter($orders, fn ($order): bool => is_array($order) && $this->orderMatchesInvoiceId($order, $invoiceId))); } private function isFulfillmentEligibleStatus(array $order): bool { $status = strtolower(trim((string)($order['status'] ?? ''))); $paymentStatus = strtolower(trim((string)($order['paymentStatus'] ?? ''))); return $this->containsFulfilledMarker($status) || $this->containsFulfilledMarker($paymentStatus); } private function containsFulfilledMarker(string $status): bool { if ($status === '') { return false; } return str_contains($status, 'paid') || str_contains($status, 'completed') || str_contains($status, 'active') || str_contains($status, 'running') || str_contains($status, 'provision') || str_contains($status, 'fulfilled') || str_contains($status, 'sent'); } private function resolveOrderKey(array $order): string { $orderId = trim((string)($order['orderId'] ?? $order['invoiceId'] ?? '')); if ($orderId !== '') { return $orderId; } $paymenterCustomerId = trim((string)($order['paymenterCustomerId'] ?? '')); $packageTier = trim((string)($order['packageTier'] ?? '')); $updatedAt = trim((string)($order['updatedAt'] ?? $order['createdAt'] ?? '')); if ($paymenterCustomerId !== '' && $packageTier !== '' && $updatedAt !== '') { return $paymenterCustomerId . ':' . $packageTier . ':' . $updatedAt; } return ''; } private function orderMatchesInvoiceId(array $order, string $invoiceId): bool { $invoiceId = strtolower($this->sanitizeReplayId($invoiceId)); if ($invoiceId === '') { return true; } $candidates = [ (string)($order['invoiceId'] ?? ''), (string)($order['invoice_id'] ?? ''), (string)($order['raw']['invoice_id'] ?? ''), (string)($order['raw']['invoiceId'] ?? ''), (string)($order['raw']['attributes']['invoice_id'] ?? ''), (string)($order['raw']['attributes']['invoiceId'] ?? ''), ]; foreach ($candidates as $candidate) { if (strtolower($this->sanitizeReplayId($candidate)) === $invoiceId) { return true; } } return false; } private function hasCurrentMonthlyPayment(array $order): bool { $paidAt = $this->resolvePaymentFreshnessTimestamp($order); if ($paidAt <= 0) { return true; } return $paidAt >= (time() - self::CURRENT_MONTHLY_PAYMENT_GRACE_SECONDS); } private function resolvePaymentFreshnessTimestamp(array $order): int { $candidates = [ (string)($order['paidAt'] ?? ''), (string)($order['paid_at'] ?? ''), (string)($order['dueAt'] ?? ''), (string)($order['due_at'] ?? ''), (string)($order['updatedAt'] ?? ''), (string)($order['updated_at'] ?? ''), (string)($order['createdAt'] ?? ''), (string)($order['created_at'] ?? ''), (string)($order['raw']['paid_at'] ?? ''), (string)($order['raw']['attributes']['paid_at'] ?? ''), (string)($order['raw']['due_at'] ?? ''), (string)($order['raw']['attributes']['due_at'] ?? ''), (string)($order['raw']['updated_at'] ?? ''), (string)($order['raw']['attributes']['updated_at'] ?? ''), (string)($order['raw']['created_at'] ?? ''), (string)($order['raw']['attributes']['created_at'] ?? ''), ]; foreach ($candidates as $candidate) { $ts = strtotime(trim($candidate)); if ($ts !== false && $ts > 0) { return $ts; } } return 0; } private function sanitizeReplayId(string $value): string { return preg_replace('/[^A-Za-z0-9_.:-]/', '', trim($value)) ?? ''; } private function resolveOrderUpdatedAt(array $order): string { $updatedAt = trim((string)($order['updatedAt'] ?? '')); if ($updatedAt !== '') { return $updatedAt; } return trim((string)($order['createdAt'] ?? '')); } private function resolvePackageTier(array $order): string { $candidates = [ (string)($order['packageTier'] ?? ''), (string)($order['packageTierHint'] ?? ''), (string)($order['productName'] ?? ''), ]; foreach ($candidates as $candidate) { $normalized = $this->normalizePackageTier($candidate); if ($normalized !== '' && $normalized !== 'free_core') { return $normalized; } } return ''; } private function normalizePackageTier(string $packageTier): string { $normalized = strtolower(trim($packageTier)); if ($normalized === '') { return ''; } if (isset(self::PACKAGE_TIER_ALIASES[$normalized])) { $normalized = self::PACKAGE_TIER_ALIASES[$normalized]; } return $normalized; } private function isAllowedPackageTier(string $packageTier): bool { return in_array($this->normalizePackageTier($packageTier), self::ALLOWED_PACKAGE_TIERS, true); } private function resolveRecipientEmail(array $order): string { $candidates = [ (string)($order['customerEmail'] ?? ''), (string)($order['mspContactEmail'] ?? ''), (string)($order['adminEmail'] ?? ''), ]; foreach ($candidates as $candidate) { $email = trim($candidate); if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) !== false) { return $email; } } return ''; } private function resolveRecipientDisplayName(array $order, string $recipientEmail): string { $candidates = [ (string)($order['customerName'] ?? ''), (string)($order['displayName'] ?? ''), (string)($order['mspContactEmail'] ?? ''), (string)($order['adminEmail'] ?? ''), ]; foreach ($candidates as $candidate) { $displayName = trim($candidate); if ($displayName !== '') { return $displayName; } } return $recipientEmail !== '' ? $recipientEmail : 'NuQloud customer'; } private function isExistingNextcloudUser(string $email): bool { if (!method_exists($this->userManager, 'getByEmail')) { return false; } try { $matches = $this->userManager->getByEmail($email); return is_array($matches) ? $matches !== [] : (bool)$matches; } catch (\Throwable) { return false; } } private function resolveInviteTtlHours(): ?int { $settings = $this->integrationConfig->getSettings(); $secondsRaw = trim((string)($settings['oidcInviteTtlSeconds'] ?? '')); if ($secondsRaw === '' || !ctype_digit($secondsRaw)) { return null; } $seconds = (int)$secondsRaw; if ($seconds <= 0) { return null; } return max(1, (int)ceil($seconds / 3600)); } private function buildInviteComment(string $orderKey, array $order, string $recipientEmail, string $packageTier): string { $parts = [ 'auto-fulfillment', 'order=' . $orderKey, 'instance=' . trim((string)($order['instanceId'] ?? '')), 'tier=' . $packageTier, 'email=' . $recipientEmail, ]; $comment = implode('; ', array_filter($parts, static fn (string $value): bool => trim($value) !== '')); return substr($comment, 0, 500); } /** @param array $inviteResponse */ private function extractInviteToken(array $inviteResponse): string { $candidates = [ $inviteResponse['token'] ?? null, $inviteResponse['invite']['token'] ?? null, $inviteResponse['invite']['inviteToken'] ?? null, $inviteResponse['data']['token'] ?? null, $inviteResponse['data']['invite']['token'] ?? null, ]; foreach ($candidates as $candidate) { if (is_string($candidate) && trim($candidate) !== '') { return trim($candidate); } } return ''; } /** @return array */ private function buildRunMessage(array $summary): string { $processed = (int)($summary['processed'] ?? 0); $fulfilled = (int)($summary['fulfilled'] ?? 0); $skipped = (int)($summary['skipped'] ?? 0); $errors = (int)($summary['errors'] ?? 0); if ($processed === 0) { return 'No eligible paid orders were found.'; } return sprintf( 'Processed %d paid orders: %d fulfilled, %d skipped, %d errors.', $processed, $fulfilled, $skipped, $errors ); } /** @param array $state */ private function buildOverview(array $state): array { $processedOrders = is_array($state['processedOrders'] ?? null) ? $state['processedOrders'] : []; $processedCount = 0; $fulfilledCount = 0; $pendingCount = 0; $errorCount = 0; foreach ($processedOrders as $entry) { if (!is_array($entry)) { continue; } $processedCount++; $fulfilledAt = trim((string)($entry['fulfilledAt'] ?? '')); $inviteToken = trim((string)($entry['inviteToken'] ?? '')); $lastError = trim((string)($entry['lastError'] ?? '')); if ($fulfilledAt !== '') { $fulfilledCount++; } elseif ($inviteToken !== '') { $pendingCount++; } if ($lastError !== '') { $errorCount++; } } return [ 'enabled' => $this->isEnabled(), 'lastRunStartedAt' => trim((string)($state['lastRunStartedAt'] ?? '')), 'lastRunCompletedAt' => trim((string)($state['lastRunCompletedAt'] ?? '')), 'lastSuccessfulRunCompletedAt' => trim((string)($state['lastSuccessfulRunCompletedAt'] ?? '')), 'lastRunStatus' => trim((string)($state['lastRunStatus'] ?? 'idle')), 'lastRunMessage' => trim((string)($state['lastRunMessage'] ?? '')), 'lastRunCount' => (int)($state['lastRunCount'] ?? 0), 'lastRunFulfilledCount' => (int)($state['lastRunFulfilledCount'] ?? 0), 'lastRunSkippedCount' => (int)($state['lastRunSkippedCount'] ?? 0), 'lastRunErrorCount' => (int)($state['lastRunErrorCount'] ?? 0), 'lastRunSince' => trim((string)($state['lastRunSince'] ?? '')), 'processedCount' => $processedCount, 'fulfilledCount' => $fulfilledCount, 'pendingCount' => $pendingCount, 'errorCount' => $errorCount, ]; } /** @param array $state */ private function updateRunState(array &$state, array $updates): void { foreach ($updates as $key => $value) { if (!is_string($key) || $key === '') { continue; } $state[$key] = $value; } } /** @return array */ private function loadState(): array { $raw = trim((string)$this->config->getAppValue(self::APP_ID, self::STATE_KEY, '')); if ($raw === '') { return []; } try { $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); return is_array($decoded) ? $decoded : []; } catch (\Throwable) { return []; } } /** @param array $state */ private function saveState(array $state): void { $state['processedOrders'] = is_array($state['processedOrders'] ?? null) ? $state['processedOrders'] : []; $encoded = json_encode($state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if (!is_string($encoded) || $encoded === '') { return; } $this->config->setAppValue(self::APP_ID, self::STATE_KEY, $encoded); } private function pruneState(array &$state): void { $processedOrders = is_array($state['processedOrders'] ?? null) ? $state['processedOrders'] : []; if ($processedOrders === []) { $state['processedOrders'] = []; return; } uasort($processedOrders, static function (array $left, array $right): int { $leftTs = strtotime(trim((string)($left['fulfilledAt'] ?? $left['lastAttemptAt'] ?? $left['inviteCreatedAt'] ?? ''))) ?: 0; $rightTs = strtotime(trim((string)($right['fulfilledAt'] ?? $right['lastAttemptAt'] ?? $right['inviteCreatedAt'] ?? ''))) ?: 0; return $rightTs <=> $leftTs; }); $state['processedOrders'] = array_slice($processedOrders, 0, self::MAX_STORED_ORDERS, true); } private function isEnabled(): bool { return $this->config->getAppValue(self::APP_ID, self::ENABLED_KEY, '0') === '1'; } 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, '')); } private function resolveConnectorAdminToken(): string { $token = trim($this->getIntegrationAppValue(self::QORTAL_INTEGRATION_KEY_CHD_ADMIN_API_TOKEN, '')); if ($token !== '') { return $token; } return trim($this->getIntegrationAppValue(self::QORTAL_INTEGRATION_KEY_CHD_REGISTRATION_TOKEN, '')); } private function getIntegrationAppValue(string $key, string $default = ''): string { return trim((string)$this->config->getAppValue(self::QORTAL_INTEGRATION_APP_ID, $key, $default)); } /** * @return array */ private function loadConnectorRoutingConfig(): array { $raw = trim((string)$this->config->getAppValue(self::APP_ID, 'connector_routing_config', '')); if ($raw === '') { return $this->normalizeConnectorRoutingConfig([]); } try { $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); return is_array($decoded) ? $this->normalizeConnectorRoutingConfig($decoded) : $this->normalizeConnectorRoutingConfig([]); } catch (\Throwable) { return $this->normalizeConnectorRoutingConfig([]); } } /** * @param array $payload * @return array */ private function normalizeConnectorRoutingConfig(array $payload): array { $profilesIn = is_array($payload['profiles'] ?? null) ? $payload['profiles'] : []; $profiles = []; foreach (['test', '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, ['test', 'production'], true)) { $activeProfile = ''; } return [ 'version' => 1, 'activeProfile' => $activeProfile, 'profiles' => $profiles, ]; } private function sanitizeConnectorBaseUrl(string $value): string { $value = trim($value); if ($value === '') { return ''; } if (!preg_match('#^https?://#i', $value)) { return ''; } return rtrim($value, '/'); } /** @param array $payload */ /** @return array */ private function requestConnectorAdminEndpoint(string $method, string $url, string $adminToken): 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, ], ]; $verb = strtoupper(trim($method)); $response = $verb === 'POST' ? $client->post($url, $options) : $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) { 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; } }