>,error?:string,reconnect?:bool,status?:int} */ public static function reports(int $userId): array { $query = ['dir' => 'desc', 'site' => self::siteKey(), 'skip' => 0, 'sort' => 'createdAt', 'take' => 50]; $path = '/api/reports?' . http_build_query($query); foreach (self::OPEN_STATUSES as $status) { $path .= '&status=' . rawurlencode($status); } $response = self::call($userId, 'GET', $path); if (!$response['ok']) { return $response; } $items = is_array($response['body']['items'] ?? null) ? $response['body']['items'] : []; $rows = array_values(array_filter(array_map([Shape::class, 'reportRow'], $items))); usort($rows, static fn (array $a, array $b): int => (int) $b['urgent'] <=> (int) $a['urgent']); return ['ok' => true, 'reports' => $rows]; } /** @return array{ok:bool,report?:array,error?:string,reconnect?:bool,status?:int} */ public static function report(int $userId, string $reportId): array { $response = self::call($userId, 'GET', '/api/reports/' . rawurlencode($reportId)); if (!$response['ok']) { return $response; } $body = is_array($response['body']) ? $response['body'] : []; if (!self::isThisSite($userId, (string) ($body['siteId'] ?? ''))) { return self::notHere(); } $report = Shape::report($body); return $report === null ? self::notHere() : ['ok' => true, 'report' => $report]; } /** @return array{ok:bool,patches?:array>,error?:string,reconnect?:bool,status?:int} */ public static function live(int $userId): array { $response = self::call($userId, 'GET', '/api/patches/live?' . http_build_query(['site' => self::siteKey()])); if (!$response['ok']) { return $response; } $items = is_array($response['body']) ? $response['body'] : []; return ['ok' => true, 'patches' => array_values(array_filter(array_map([Shape::class, 'patch'], $items)))]; } /** @return array{ok:bool,previewUrl?:string,error?:string,reconnect?:bool,status?:int} */ public static function preview(int $userId, string $patchId): array { $owned = self::ownPatch($userId, $patchId); if (!$owned['ok']) { return $owned; } $response = self::call($userId, 'POST', '/api/patches/' . rawurlencode($patchId) . '/preview', []); if (!$response['ok']) { return $response; } $url = is_string($response['body']['previewUrl'] ?? null) ? esc_url_raw($response['body']['previewUrl'], ['http', 'https']) : ''; return $url === '' ? ['error' => __('Sarcio could not open a preview right now.', 'sarcio'), 'ok' => false] : ['ok' => true, 'previewUrl' => $url]; } /** * Approves as the member. The proof (their Sarcio password or an * authenticator code) is passed through when Sarcio asks for it and is * never stored or logged here. * * @param array{confirmCode?:string,confirmPassword?:string} $proof * @return array{ok:bool,error?:string,reconfirm?:bool,reconnect?:bool,status?:int} */ public static function approve(int $userId, string $patchId, ?string $riskTier, array $proof): array { $owned = self::ownPatch($userId, $patchId); if (!$owned['ok']) { return $owned; } $body = $riskTier !== null ? ['riskTier' => $riskTier] : []; foreach (['confirmCode' => 32, 'confirmPassword' => 512] as $field => $max) { if (isset($proof[$field]) && $proof[$field] !== '') { $body[$field] = mb_substr($proof[$field], 0, $max); } } return self::outcome(self::call($userId, 'POST', '/api/patches/' . rawurlencode($patchId) . '/approve', $body)); } /** @return array{ok:bool,error?:string,reconnect?:bool,status?:int} */ public static function reject(int $userId, string $patchId): array { $owned = self::ownPatch($userId, $patchId); if (!$owned['ok']) { return $owned; } return self::outcome(self::call($userId, 'POST', '/api/patches/' . rawurlencode($patchId) . '/reject', [])); } /** * @param array{confirmCode?:string,confirmPassword?:string} $proof * @return array{ok:bool,error?:string,reconfirm?:bool,reconnect?:bool,status?:int} */ public static function retire(int $userId, string $patchId, array $proof): array { $owned = self::ownPatch($userId, $patchId); if (!$owned['ok']) { return $owned; } $body = []; foreach (['confirmCode' => 32, 'confirmPassword' => 512] as $field => $max) { if (isset($proof[$field]) && $proof[$field] !== '') { $body[$field] = mb_substr($proof[$field], 0, $max); } } $result = self::outcome(self::call($userId, 'POST', '/api/patches/' . rawurlencode($patchId) . '/retire', $body)); if ($result['ok'] || !empty($result['reconfirm']) || !empty($result['reconnect'])) { return $result; } $status = (int) ($result['status'] ?? 0); $result['error'] = $status === 403 ? __('Your Sarcio server does not let WordPress turn fixes off yet. Turn it off in Sarcio under Live, or ask whoever runs your Sarcio server to update it.', 'sarcio') : __('The fix could not be turned off right now. It is still live. Try again in a moment.', 'sarcio'); return $result; } /** @return array{ok:bool,error?:string,reconnect?:bool,status?:int} */ public static function redraft(int $userId, string $reportId, string $note): array { $report = self::report($userId, $reportId); if (!$report['ok']) { return $report; } $body = $note !== '' ? ['steeringNote' => mb_substr($note, 0, 2000)] : []; return self::outcome(self::call($userId, 'POST', '/api/reports/' . rawurlencode($reportId) . '/regenerate', $body)); } /** * @param array{ok:bool,body?:mixed,error?:string,reconnect?:bool,status?:int,code?:string} $response * @return array{ok:bool,error?:string,reconfirm?:bool,reconnect?:bool,status?:int} */ private static function outcome(array $response): array { if ($response['ok']) { return ['ok' => true]; } if (($response['code'] ?? '') === 'reconfirm_required') { return ['error' => $response['error'] ?? '', 'ok' => false, 'reconfirm' => true, 'status' => 403]; } unset($response['body'], $response['code']); return $response; } /** @return array{ok:bool,error?:string,reconnect?:bool,status?:int} */ private static function ownPatch(int $userId, string $patchId): array { $response = self::call($userId, 'GET', '/api/patches/' . rawurlencode($patchId)); if (!$response['ok']) { return $response; } return self::isThisSite($userId, (string) ($response['body']['siteId'] ?? '')) ? ['ok' => true] : self::notHere(); } private static function isThisSite(int $userId, string $siteId): bool { $own = self::siteId($userId); return $own !== '' && $siteId !== '' && hash_equals($own, $siteId); } private static function siteId(int $userId): string { $key = self::siteKey(); $cacheKey = 'sarcio_inbox_site_' . md5($key . '|' . Options::apiUrl()); $cached = get_transient($cacheKey); if (is_string($cached) && $cached !== '') { return $cached; } $response = self::call($userId, 'GET', '/api/sites/' . rawurlencode($key)); $id = $response['ok'] && is_string($response['body']['id'] ?? null) ? $response['body']['id'] : ''; if ($id !== '') { set_transient($cacheKey, $id, self::SITE_CACHE_SEC); } return $id; } /** @return array{ok:false,error:string,status:int} */ private static function notHere(): array { return ['error' => __('That report is not for this site.', 'sarcio'), 'ok' => false, 'status' => 404]; } private static function siteKey(): string { return (string) Options::get('site_key'); } /** * One call as the member, refreshing their sign-in once when Sarcio * refuses the access token. * * @param array|null $body * @return array{ok:bool,body?:mixed,error?:string,code?:string,reconnect?:bool,status?:int} */ private static function call(int $userId, string $method, string $path, ?array $body = null): array { $token = Account::accessToken($userId); if (!isset($token['access'])) { return self::signIn($token); } $response = self::send($method, $path, $token['access'], $body); if ($response['status'] === 401) { $token = Account::accessToken($userId, true); if (!isset($token['access'])) { return self::signIn($token); } $response = self::send($method, $path, $token['access'], $body); if ($response['status'] === 401) { Account::forget($userId); return ['ok' => false, 'reconnect' => true, 'status' => 401]; } } if ($response['status'] >= 200 && $response['status'] < 300) { return ['body' => $response['body'], 'ok' => true, 'status' => $response['status']]; } return self::failure($response); } /** * @param array{error?:string,reconnect?:bool} $token * @return array{ok:false,error?:string,reconnect?:bool,status:int} */ private static function signIn(array $token): array { return isset($token['reconnect']) ? ['ok' => false, 'reconnect' => true, 'status' => 401] : ['error' => $token['error'] ?? self::unreachable(), 'ok' => false, 'status' => 0]; } /** * @param array{status:int,body:mixed} $response * @return array{ok:false,error:string,code?:string,status:int} */ private static function failure(array $response): array { $status = $response['status']; $body = is_array($response['body']) ? $response['body'] : []; $message = is_string($body['message'] ?? null) ? sanitize_text_field($body['message']) : ''; $code = is_string($body['code'] ?? null) ? sanitize_key($body['code']) : ''; $error = match (true) { $status === 0 => self::unreachable(), $status === 402, $status === 403, $status === 409, $status === 422 => $message !== '' ? $message : __('Sarcio did not allow that.', 'sarcio'), $status === 404 => __('That report is no longer in Sarcio.', 'sarcio'), $status === 429 => __('Too many requests at once. Wait a moment and try again.', 'sarcio'), default => __('Sarcio could not do that right now. Try again in a moment.', 'sarcio'), }; return ['error' => $error, 'ok' => false, 'status' => $status] + ($code !== '' ? ['code' => $code] : []); } private static function unreachable(): string { return __('Could not reach Sarcio. Check your connection and try again.', 'sarcio'); } /** * @param array|null $body * @return array{status:int,body:mixed} */ private static function send(string $method, string $path, string $access, ?array $body): array { $args = [ 'headers' => ['Accept' => 'application/json', 'Authorization' => 'Bearer ' . $access], 'redirection' => 0, 'timeout' => self::TIMEOUT_SEC, ]; $url = Options::apiUrl() . $path; if ($method === 'POST') { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = (string) wp_json_encode($body === [] || $body === null ? new \stdClass() : $body); $response = wp_remote_post($url, $args); } else { $response = wp_remote_get($url, $args); } if (is_wp_error($response)) { return ['body' => null, 'status' => 0]; } return [ 'body' => json_decode((string) wp_remote_retrieve_body($response), true), 'status' => (int) wp_remote_retrieve_response_code($response), ]; } }