[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'; public function register(): void { add_action('rest_api_init', [$this, 'registerRoutes']); } public static function decideCapability(): string { $capability = apply_filters('sarcio_inbox_decide_capability', ReviewPage::CAPABILITY); return is_string($capability) && $capability !== '' ? $capability : ReviewPage::CAPABILITY; } public function registerRoutes(): void { register_rest_route(self::NAMESPACE, '/inbox/reports', [ 'callback' => [$this, 'reports'], 'methods' => WP_REST_Server::READABLE, 'permission_callback' => [self::class, 'canRead'], ]); register_rest_route(self::NAMESPACE, '/inbox/reports/' . self::ID, [ 'callback' => [$this, 'report'], 'methods' => WP_REST_Server::READABLE, 'permission_callback' => [self::class, 'canRead'], ]); register_rest_route(self::NAMESPACE, '/inbox/reports/' . self::ID . '/redraft', [ 'args' => ['note' => ['type' => 'string', 'maxLength' => 2000]], 'callback' => [$this, 'redraft'], 'methods' => WP_REST_Server::CREATABLE, 'permission_callback' => [self::class, 'canDecide'], ]); register_rest_route(self::NAMESPACE, '/inbox/live', [ 'callback' => [$this, 'live'], 'methods' => WP_REST_Server::READABLE, 'permission_callback' => [self::class, 'canRead'], ]); register_rest_route(self::NAMESPACE, '/inbox/patches/' . self::ID . '/preview', [ 'callback' => [$this, 'preview'], 'methods' => WP_REST_Server::CREATABLE, 'permission_callback' => [self::class, 'canRead'], ]); register_rest_route(self::NAMESPACE, '/inbox/patches/' . self::ID . '/approve', [ 'args' => [ 'confirmCode' => ['type' => 'string', 'maxLength' => 32], 'confirmPassword' => ['type' => 'string', 'maxLength' => 512], 'riskTier' => ['enum' => ['low', 'elevated'], 'type' => 'string'], ], 'callback' => [$this, 'approve'], 'methods' => WP_REST_Server::CREATABLE, 'permission_callback' => [self::class, 'canDecide'], ]); register_rest_route(self::NAMESPACE, '/inbox/patches/' . self::ID . '/reject', [ 'callback' => [$this, 'reject'], 'methods' => WP_REST_Server::CREATABLE, 'permission_callback' => [self::class, 'canDecide'], ]); register_rest_route(self::NAMESPACE, '/inbox/patches/' . self::ID . '/retire', [ 'args' => [ 'confirmCode' => ['type' => 'string', 'maxLength' => 32], 'confirmPassword' => ['type' => 'string', 'maxLength' => 512], ], 'callback' => [$this, 'retire'], 'methods' => WP_REST_Server::CREATABLE, 'permission_callback' => [self::class, 'canDecide'], ]); register_rest_route(self::NAMESPACE, '/inbox/update-checks/' . self::ID . '/(?Pkeep|rollback|retry)', [ 'callback' => [$this, 'updateCheck'], 'methods' => WP_REST_Server::CREATABLE, 'permission_callback' => [self::class, 'canDecideUpdate'], ]); } public static function canRead(): bool { return is_user_logged_in() && current_user_can(ReviewPage::CAPABILITY); } public static function canDecide(): bool { return self::canRead() && current_user_can(self::decideCapability()); } public static function canDecideUpdate(): bool { return self::canDecide() && (current_user_can('update_plugins') || current_user_can('update_themes')); } /** * The owner's answer to an update report: keep things as they are, put * the earlier version back (the journal's undo), or try the update again. * The journal entry must be the safe update of the same item, so a report * can only ever undo the update it is about. */ public function updateCheck(WP_REST_Request $request): WP_REST_Response { $reportId = (string) $request->get_param('id'); $action = (string) $request->get_param('action'); $fetched = Client::report(get_current_user_id(), $reportId); if (!$fetched['ok']) { return self::respond($fetched); } $update = $fetched['report']['update'] ?? null; if (!is_array($update)) { return self::respond(['error' => __('That report is not about an update.', 'sarcio'), 'ok' => false, 'status' => 404]); } if (!current_user_can($update['kind'] === 'theme' ? 'update_themes' : 'update_plugins')) { return self::respond(['error' => __('You do not have permission to change updates on this site.', 'sarcio'), 'ok' => false, 'status' => 403]); } $entry = $update['journalId'] > 0 ? Journal::get($update['journalId']) : null; $matches = $entry !== null && $entry['ability'] === SafeUpdate::ABILITY && ($entry['before']['slug'] ?? '') === $update['slug'] && ($entry['before']['kind'] ?? '') === $update['kind']; $result = match ($action) { 'keep' => ['ok' => true, 'decision' => 'kept', 'message' => __('Kept as it is.', 'sarcio')], 'rollback' => self::rollback($update, $matches ? $entry : null), default => self::retry($update, $matches ? $entry : null), }; if ($result['ok']) { SafeUpdate::decide($reportId, (string) $result['decision']); InboxSummary::forget(); unset($result['decision']); } return self::respond($result); } /** * @param array $update * @param array|null $entry * @return array */ private static function rollback(array $update, ?array $entry): array { if ($update['outcome'] === 'rolled_back' || ($entry !== null && $entry['undoneAt'] !== null)) { return ['error' => __('The earlier version is already back.', 'sarcio'), 'ok' => false, 'status' => 409]; } if ($entry === null) { return ['error' => __('This site no longer has the record of that update, so it cannot be put back from here.', 'sarcio'), 'ok' => false, 'status' => 404]; } $undone = Catalog::undo((int) $entry['id']); if (!$undone['ok']) { return ['error' => $undone['error'] ?? __('The earlier version could not be put back.', 'sarcio'), 'ok' => false, 'status' => 409]; } return [ 'decision' => 'rolled_back', /* translators: 1: the plugin or theme name, 2: the version put back. */ 'message' => sprintf(__('%1$s %2$s is back.', 'sarcio'), $update['name'], $update['from']), 'ok' => true, ]; } /** * @param array $update * @param array|null $entry * @return array */ private static function retry(array $update, ?array $entry): array { if ($entry === null) { return ['error' => __('This site has no record of updating that, so it is not updated from here.', 'sarcio'), 'ok' => false, 'status' => 404]; } if ($update['outcome'] !== 'rolled_back') { return ['error' => __('That update is still in place.', 'sarcio'), 'ok' => false, 'status' => 409]; } $run = Catalog::run(SafeUpdate::ABILITY, ['kind' => $update['kind'], 'slug' => $update['slug']], true); if (!$run['ok']) { return ['error' => $run['error'] ?? __('The update could not run.', 'sarcio'), 'ok' => false, 'status' => 409]; } return ['decision' => 'updated_again', 'message' => (string) ($run['output']['summary'] ?? ''), 'ok' => true]; } public function reports(WP_REST_Request $request): WP_REST_Response { InboxSummary::forget(); return self::respond(Client::reports(get_current_user_id())); } public function report(WP_REST_Request $request): WP_REST_Response { return self::respond(Client::report(get_current_user_id(), (string) $request->get_param('id'))); } public function live(WP_REST_Request $request): WP_REST_Response { return self::respond(Client::live(get_current_user_id())); } public function preview(WP_REST_Request $request): WP_REST_Response { return self::respond(Client::preview(get_current_user_id(), (string) $request->get_param('id'))); } public function approve(WP_REST_Request $request): WP_REST_Response { $tier = $request->get_param('riskTier'); $proof = []; foreach (['confirmCode', 'confirmPassword'] as $field) { $value = $request->get_param($field); if (is_string($value) && $value !== '') { $proof[$field] = $field === 'confirmCode' ? sanitize_text_field($value) : $value; } } $result = Client::approve(get_current_user_id(), (string) $request->get_param('id'), in_array($tier, ['low', 'elevated'], true) ? $tier : null, $proof); InboxSummary::forget(); return self::respond($result); } public function reject(WP_REST_Request $request): WP_REST_Response { $result = Client::reject(get_current_user_id(), (string) $request->get_param('id')); InboxSummary::forget(); return self::respond($result); } public function retire(WP_REST_Request $request): WP_REST_Response { $proof = []; foreach (['confirmCode', 'confirmPassword'] as $field) { $value = $request->get_param($field); if (is_string($value) && $value !== '') { $proof[$field] = $field === 'confirmCode' ? sanitize_text_field($value) : $value; } } $result = Client::retire(get_current_user_id(), (string) $request->get_param('id'), $proof); InboxSummary::forget(); return self::respond($result); } public function redraft(WP_REST_Request $request): WP_REST_Response { $note = $request->get_param('note'); return self::respond(Client::redraft(get_current_user_id(), (string) $request->get_param('id'), is_string($note) ? sanitize_textarea_field($note) : '')); } /** @param array $result */ private static function respond(array $result): WP_REST_Response { if ($result['ok']) { return new WP_REST_Response($result, 200); } $status = (int) ($result['status'] ?? 502); $http = match (true) { !empty($result['reconnect']) => 401, !empty($result['reconfirm']) => 403, $status >= 400 && $status < 500 => $status, default => 502, }; unset($result['status']); return new WP_REST_Response($result, $http); } }