http = $http ?? new Client(); } public function reveal(string $accountId, int $actorId, string $purpose): array { $template = (string) config('store_account_password.url'); $token = (string) config('store_account_password.token'); if (substr_count($template, '{id}') !== 1 || parse_url($template, PHP_URL_SCHEME) !== 'https' || ! preg_match('/^[A-Za-z0-9_-]{43,128}$/D', $token)) { return ['status' => 503]; } try { $response = $this->http->request('POST', str_replace('{id}', rawurlencode($accountId), $template), [ 'headers' => ['Authorization' => 'Bearer '.$token, 'Accept' => 'application/json'], 'json' => ['requesterId' => 'finance-user-'.$actorId, 'purpose' => $purpose], 'verify' => true, 'allow_redirects' => false, 'http_errors' => false, 'connect_timeout' => 3, 'timeout' => 10, 'sink' => fopen('php://memory', 'w+'), ]); $status = $response->getStatusCode(); if ($response->getBody()->isSeekable()) { $response->getBody()->rewind(); } $body = $response->getBody()->read(65537); $response->getBody()->close(); if (strlen($body) > 65536) { return ['status' => 503]; } $data = json_decode($body, true); unset($body); $requestId = $data['requestId'] ?? null; if (! is_string($requestId) || ! preg_match('/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/iD', $requestId)) { $requestId = null; } if ($status !== 200) { return ['status' => in_array($status, [404, 429], true) ? $status : 503, 'upstream_request_id' => $requestId]; } if (! $requestId || ! preg_match('/(?:^|,)\s*no-store\s*(?:,|$)/i', $response->getHeaderLine('Cache-Control')) || ! is_string($data['password'] ?? null) || $data['password'] === '') { return ['status' => 503, 'upstream_request_id' => $requestId]; } return ['status' => 200, 'password' => $data['password'], 'upstream_request_id' => $requestId]; } catch (Throwable) { // Never log exception objects: transport errors may contain response bodies. return ['status' => 503]; } } public function revealVisiblePage(array $accountIds, int $actorId): array { $url = (string) config('store_account_password.visible_page_url'); $token = (string) config('store_account_password.token'); $accountIds = array_values($accountIds); if (parse_url($url, PHP_URL_SCHEME) !== 'https' || ! preg_match('/^[A-Za-z0-9_-]{43,128}$/D', $token) || count($accountIds) < 1 || count($accountIds) > 50 || count(array_unique($accountIds)) !== count($accountIds) || collect($accountIds)->contains(fn ($id) => ! is_string($id) || ! preg_match('/^[A-Za-z0-9_-]{1,128}$/D', $id))) { return ['status' => 503]; } $requesterId = 'finance-user-'.$actorId; $chunks = []; $chunk = []; foreach ($accountIds as $accountId) { $candidate = [...$chunk, $accountId]; $payload = ['requesterId' => $requesterId, 'purpose' => 'view', 'accountIds' => $candidate]; if ($chunk !== [] && (count($candidate) > 25 || strlen((string) json_encode($payload)) > 1000)) { $chunks[] = $chunk; $chunk = [$accountId]; } else { $chunk = $candidate; } } $chunks[] = $chunk; $items = []; foreach ($chunks as $accountIdChunk) { $result = $this->revealVisiblePageChunk($url, $token, $requesterId, $accountIdChunk); if (($result['status'] ?? 503) !== 200) { foreach ($items as &$item) { unset($item['password']); } unset($item, $items); return ['status' => $result['status'] ?? 503]; } $items += $result['items']; } return ['status' => 200, 'items' => $items]; } private function revealVisiblePageChunk(string $url, string $token, string $requesterId, array $accountIds): array { try { $response = $this->http->request('POST', $url, [ 'headers' => ['Authorization' => 'Bearer '.$token, 'Accept' => 'application/json'], 'json' => ['requesterId' => $requesterId, 'purpose' => 'view', 'accountIds' => $accountIds], 'verify' => true, 'allow_redirects' => false, 'http_errors' => false, 'connect_timeout' => 3, 'timeout' => 10, 'sink' => fopen('php://memory', 'w+'), ]); $status = $response->getStatusCode(); if ($response->getBody()->isSeekable()) { $response->getBody()->rewind(); } $body = $response->getBody()->read(131073); $response->getBody()->close(); if (strlen($body) > 131072) { return ['status' => 503]; } $data = json_decode($body, true); unset($body); if ($status !== 200) { return ['status' => $status === 429 ? 429 : 503]; } if (! preg_match('/(?:^|,)\s*no-store\s*(?:,|$)/i', $response->getHeaderLine('Cache-Control')) || ! $this->uuid($data['requestId'] ?? null) || ! is_array($data['items'] ?? null) || count($data['items']) !== count($accountIds)) { return ['status' => 503]; } $items = []; foreach ($data['items'] as $item) { $accountId = $item['accountId'] ?? null; $itemStatus = $item['status'] ?? null; if (! is_string($accountId) || ! in_array($accountId, $accountIds, true) || isset($items[$accountId]) || ! $this->uuid($item['requestId'] ?? null) || ! in_array($itemStatus, [200, 404, 429, 503], true) || ($itemStatus === 200 && (! is_string($item['password'] ?? null) || $item['password'] === '')) || ($itemStatus !== 200 && array_key_exists('password', $item))) { return ['status' => 503]; } $items[$accountId] = [ 'status' => $itemStatus, 'password' => $itemStatus === 200 ? $item['password'] : null, 'upstream_request_id' => $item['requestId'], ]; } if (array_keys($items) !== $accountIds) { return ['status' => 503]; } return ['status' => 200, 'items' => $items]; } catch (Throwable) { return ['status' => 503]; } } private function uuid(mixed $value): bool { return is_string($value) && (bool) preg_match('/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/iD', $value); } }