health = $health; $this->cleanup = $cleanup; $this->connector = $connector; } private function health(): SiteHealth { return $this->health ?? new SiteHealth(); } private function cleanup(): SiteCleanup { return $this->cleanup ?? new SiteCleanup(); } private function connector(): SiteConnector { return $this->connector ?? new SiteConnector(new RestClient()); } private function credentials(): Credentials { return new Credentials(); } public function index(WP_REST_Request $request): WP_REST_Response { $page = max(1, (int) ($request->get_param('page') ?: 1)); $perPage = max(1, min(100, (int) ($request->get_param('per_page') ?: 10))); $search = sanitize_text_field((string) ($request->get_param('search') ?: '')); $statusFilter = sanitize_text_field((string) ($request->get_param('status') ?: '')); $sites = array_map(static fn(TargetSite $site) => $site->toArray(), TargetSite::listAll()); // Fleet-wide for the topbar pill; $sites is filtered below. ['total_all' => $totalAll, 'healthy_all' => $healthyAll] = TargetSite::fleetCounts(); $statuses = array_values(array_unique(array_filter( array_column($sites, 'status'), static fn($status) => $status !== '' && $status !== null ))); // stripos avoids an mb_* dependency, which is not guaranteed on every host. if ($search !== '') { $sites = array_values(array_filter($sites, static function (array $site) use ($search): bool { return stripos((string) ($site['name'] ?? ''), $search) !== false || stripos((string) ($site['url'] ?? ''), $search) !== false; })); } if ($statusFilter !== '') { $sites = array_values(array_filter( $sites, static fn(array $site) => ($site['status'] ?? '') === $statusFilter )); } $total = count($sites); $totalPages = $perPage > 0 ? (int) max(1, ceil($total / $perPage)) : 1; $page = min($page, $totalPages); $sliced = array_slice($sites, ($page - 1) * $perPage, $perPage); // What removing each visible site would take: linked hub posts, and people's access. $linkedCounts = TargetSite::linkedPostCounts(); $accessLoss = PermissionStore::accessLoss(array_column($sliced, 'id'), $this->connectedIds()); $sliced = array_map(static function (array $row) use ($linkedCounts, $accessLoss): array { $id = (int) ($row['id'] ?? 0); $row['linked_posts'] = (int) ($linkedCounts[$id] ?? 0); $row['access_loss'] = $accessLoss[$id] ?? ['people' => 0, 'stranded' => 0]; return $row; }, $sliced); return $this->sendSuccess([ 'items' => $sliced, 'total' => $total, 'total_all' => $totalAll, 'healthy_all' => $healthyAll, 'statuses' => $statuses, 'page' => $page, 'per_page' => $perPage, 'total_pages' => $totalPages, 'checks_pending' => $this->pendingHealthChecks(), ]); } /** Health checks still queued fleet-wide; the paged rows cannot answer that themselves. */ private function pendingHealthChecks(): int { // The status list needs Action Scheduler 3.3. if (!Scheduler::isAvailable()) { return 0; } // A running check has not written its row yet, so it counts; the client stops polling at zero. return count((array) as_get_scheduled_actions([ 'hook' => Scheduler::HOOK_HEALTH_SITE, 'status' => ['pending', 'in-progress'], 'group' => 'fspd', 'per_page' => 200, ], 'ids')); } /** Fans out one async health job per site and returns immediately; the client polls /sites. */ public function recheck(WP_REST_Request $request): WP_REST_Response { // The de-dup check below needs Action Scheduler 3.3, which isAvailable() checks for. if (Scheduler::isAvailable()) { foreach (TargetSite::listAll() as $site) { $args = [['site_id' => $site->getId()]]; if (!as_has_scheduled_action(Scheduler::HOOK_HEALTH_SITE, $args, 'fspd')) { as_enqueue_async_action(Scheduler::HOOK_HEALTH_SITE, $args, 'fspd'); } } } return $this->index($request); } /** Re-checks one site's reachability and returns its updated row. */ public function recheckOne(WP_REST_Request $request): WP_REST_Response { $site = TargetSite::find($this->routeId($request, 'id')); if (!$site) { return $this->sendError(__('Site not found.', 'publishdeck'), 404, 'fspd_not_found'); } // refresh() mutates the in-memory row, so toArray() is fresh without a re-read. $this->health()->refresh($site); // The client patches the row in place, so the fleet counts come along for the topbar pill. return $this->sendSuccess(['site' => $site->toArray()] + TargetSite::fleetCounts()); } public function store(WP_REST_Request $request): WP_REST_Response { $url = (string) $request->get_param('url'); // HTTPS is required except under the master local flag; the host flag never relaxes it. if (!Application::mayReceiveCredentials($url)) { return $this->sendError( __('Site URL must use HTTPS.', 'publishdeck'), 422, 'fspd_https_required' ); } try { Ssrf::assertSafeUrl($url); } catch (UnsafeUrlException $exception) { return $this->sendError($exception->localizedCause(), 422, 'fspd_blocked_url'); } if (is_string(wp_parse_url($url, PHP_URL_USER)) || is_string(wp_parse_url($url, PHP_URL_PASS))) { return $this->sendError( __('Site URL must not contain embedded credentials.', 'publishdeck'), 422, 'fspd_invalid_url' ); } $port = wp_parse_url($url, PHP_URL_PORT); $allowedPorts = (array) apply_filters('fspd_allowed_site_ports', [80, 443, 8080]); if (!Application::allowLocal() && $port !== null && !in_array((int) $port, $allowedPorts, true)) { return $this->sendError( __('WordPress cannot reach sites on that port.', 'publishdeck'), 422, 'fspd_invalid_url' ); } $bounded = [ 'name' => $request->get_param('name'), 'url' => $url, 'username' => $request->get_param('username'), ]; foreach ($bounded as $value) { if (strlen((string) $value) > 191) { return $this->sendError( __('Site name, URL and username must each be 191 characters or fewer.', 'publishdeck'), 422, 'fspd_invalid_url' ); } } $username = (string) $request->get_param('username'); $password = (string) $request->get_param('app_password'); $encryptedPassword = $this->credentials()->encrypt($password, $url, $username); $name = (string) $request->get_param('name'); $pushAsDraft = $request->get_param('push_as_draft'); // Only this wizard's claim may overwrite working credentials; an unproven attempt is replaced. $claim = (string) $request->get_param('claim'); $siteId = (int) $request->get_param('site_id'); // A wizard re-saving a site that was replaced or pruned learns so first. if ($claim !== '' && $siteId > 0 && !TargetSite::find($siteId)) { return $this->siteGone(); } $existing = TargetSite::findByUrl($url); if ($existing && $this->replaceAttempt($existing, $claim)) { $existing = null; } $movedSite = $existing ? null : $this->insecureTwin($url); $existing = $existing ?? $movedSite; if ($existing) { $ownSite = $existing->holdsClaim($claim); if (!$ownSite && $claim !== '' && $siteId === $existing->getId()) { return $this->claimLost(); } if (!$ownSite && $existing->getStatus() === 'pending') { return $this->sendError( __('Another session is connecting this site. Try again in a few minutes.', 'publishdeck'), 409, 'fspd_site_pending' ); } // Only a site that once connected is reconnected; this wizard's own attempt is still its own. $reconnect = $existing->isConnected() && ($movedSite !== null || $existing->getStatus() === 'needs_reconnection'); if (!$reconnect && !$ownSite) { return $this->sendError( __('This site is already connected to the hub.', 'publishdeck'), 409, 'fspd_site_exists' ); } $oldUrl = $existing->getUrl(); $oldName = $existing->getName(); $patch = [ 'name' => $name, 'url' => trailingslashit($url), 'username' => $username, ]; // New sign-in details stay pending until a Test passes; an unchanged save keeps the status. if ($reconnect || $this->signInChanged($existing, $url, $username, $password)) { $patch['app_password'] = $encryptedPassword; $patch['status'] = 'pending'; } if ($pushAsDraft !== null) { $patch['push_as_draft'] = (bool) $pushAsDraft; } // Written against the credentials just read, so a save that landed meanwhile is not overwritten. if (!$existing->updateIfCredentials($patch, $existing->getEncryptedPassword())) { return $this->claimLost(); } // Once per wizard: a Back and re-test holds the claim and is not a new reconnection. if ($reconnect && !$ownSite) { (new Logger())->audit( 'site_reconnected', 'reconnected site “' . $oldName . '”' . ($existing->getName() !== $oldName ? ' as “' . $existing->getName() . '”' : '') . ($movedSite ? ', moving it from ' . $oldUrl . ' to ' . $existing->getUrl() : ''), $existing->getId() ); } // A reconnected site predates this wizard, so it is not the wizard's to discard. return $this->sendSuccess( $existing->toArray() + ['reconnected' => $reconnect, 'claim' => $existing->claimToken()], 200 ); } try { $site = TargetSite::create([ 'name' => $name, 'url' => $url, 'username' => $username, 'app_password' => $encryptedPassword, 'push_as_draft' => (bool) $pushAsDraft, ]); } catch (\RuntimeException $exception) { // Losing a race for the same address is a conflict, not a server error. $winner = TargetSite::findByUrl($url); if ($winner) { // An attempt is someone's wizard, whatever its claim reads; only a connected site is "connected". return $winner->getStatus() === 'pending' || !$winner->isConnected() ? $this->sendError( __('Another session is connecting this site. Try again in a few minutes.', 'publishdeck'), 409, 'fspd_site_pending' ) : $this->sendError( __('This site is already connected to the hub.', 'publishdeck'), 409, 'fspd_site_exists' ); } return $this->sendError(__('Could not save the site.', 'publishdeck'), 500, 'fspd_db_error'); } // Not announced yet: it joins the fleet, and the log, when its Test first passes. return $this->sendSuccess($site->toArray() + ['claim' => $site->claimToken()], 201); } /** * The status a failed Test leaves; a rejected sign-in or an already flagged site needs reconnection. * * @param array $checks */ private function failedTestStatus(TargetSite $site, array $checks): string { // A claimed or never-connected site stays the wizard's; the claim lapses on its own. if ($site->getStatus() === 'pending' || !$site->isConnected()) { return 'pending'; } foreach ($checks as $check) { if (in_array($check['id'], ['credentials', 'app_passwords'], true) && !$check['passed']) { return 'needs_reconnection'; } } return $site->getStatus() === 'needs_reconnection' ? 'needs_reconnection' : 'warning'; } /** @return int[] every connected site's id */ private function connectedIds(): array { return array_map(static fn(TargetSite $site) => $site->getId(), TargetSite::listAll()); } /** * The audit's account of who a removal cut off; untranslated, like every recorded entry. * * @param array{people:int,stranded:int} $loss */ private function lossNote(array $loss): string { if ($loss['people'] === 0) { return ''; } return sprintf('; %d %s lost access to it', $loss['people'], $loss['people'] === 1 ? 'person' : 'people') . ($loss['stranded'] > 0 ? sprintf(', %d now with access to no site', $loss['stranded']) : ''); } /** The site a wizard was working on no longer exists; the wizard starts over from Connect. */ private function siteGone(): WP_REST_Response { return $this->sendError(__('Site not found.', 'publishdeck'), 404, 'fspd_not_found'); } private function claimLost(): WP_REST_Response { return $this->sendError( __('This site was reconnected from another session. Close this and start again.', 'publishdeck'), 409, 'fspd_site_taken' ); } /** Whether this save changes how the hub signs in to the site: its address, user or password. */ private function signInChanged(TargetSite $site, string $url, string $username, string $password): bool { if ($site->getUrl() !== trailingslashit($url) || $site->getUsername() !== $username) { return true; } try { return !hash_equals($this->credentials()->passwordFor($site), $password); } catch (CredentialsDecryptException $exception) { return true; } } /** The http:// site an https:// address replaces, once that site can no longer be sent its password. */ private function insecureTwin(string $url): ?TargetSite { if (stripos($url, 'https://') !== 0) { return null; } $twin = TargetSite::findByUrl('http://' . substr($url, strlen('https://'))); return $twin && $twin->isConnected() && !Application::mayReceiveCredentials($twin->getUrl()) ? $twin : null; } /** Deletes another wizard's attempt at this address, so this one can start its own. */ private function replaceAttempt(TargetSite $site, string $claim): bool { if ($site->isConnected() || $site->holdsClaim($claim) || !$site->deleteAttempt()) { return false; } $this->cleanup()->purge($site->getId()); return true; } /** The draft choice of the wizard that connected a site; nothing else, and never the password. */ public function update(WP_REST_Request $request): WP_REST_Response { $site = TargetSite::find($this->routeId($request, 'id')); if (!$site) { return $this->sendError(__('Site not found.', 'publishdeck'), 404, 'fspd_not_found'); } // As with every save from the wizard, only the session holding this site's claim may make it. $patch = ['push_as_draft' => (bool) $request->get_param('push_as_draft')]; if ( !$site->holdsClaim((string) $request->get_param('claim')) || !$site->updateIfCredentials($patch, $site->getEncryptedPassword()) ) { return $this->claimLost(); } return $this->sendSuccess($site->toArray() + ['claim' => $site->claimToken()]); } public function destroy(WP_REST_Request $request): WP_REST_Response { $site = TargetSite::find($this->routeId($request, 'id')); if (!$site) { return $this->sendError(__('Site not found.', 'publishdeck'), 404, 'fspd_not_found'); } // A discarding wizard sends its claim; a site another session took over is not deleted. $claim = (string) $request->get_param('claim'); if ($claim !== '' && !$site->holdsClaim($claim)) { return $this->claimLost(); } // Read before deletion for the audit; an attempt that never connected is not logged. $siteName = $site->getName(); $siteId = $site->getId(); $loss = null; if ($site->isConnected()) { $loss = PermissionStore::accessLoss([$siteId], $this->connectedIds())[$siteId]; } $this->cleanup()->purge($siteId); $site->delete(); if ($loss !== null) { (new Logger())->audit('site_removed', 'removed site “' . $siteName . '”' . $this->lossNote($loss), $siteId); } return $this->sendSuccess(null, 204); } public function test(WP_REST_Request $request): WP_REST_Response { $site = TargetSite::find($this->routeId($request, 'id')); if (!$site) { return $this->sendError(__('Site not found.', 'publishdeck'), 404, 'fspd_not_found'); } // A claimed site, or an attempt whose claim has lapsed, is tested only by the session holding it. $claim = (string) $request->get_param('claim'); $claimed = $claim !== '' || $site->getStatus() === 'pending' || !$site->isConnected(); if ($claimed && !$site->holdsClaim($claim)) { return $this->claimLost(); } try { $password = $this->credentials()->passwordFor($site); } catch (CredentialsDecryptException $exception) { $site->flagNeedsReconnection(); return $this->sendError($exception->localizedCause(), 422, $exception->errorCode()); } $result = $this->connector()->testConnection($site->getUrl(), $site->getUsername(), $password); $newStatus = $result['success'] ? 'active' : $this->failedTestStatus($site, $result['checks'] ?? []); $firstPass = $result['success'] && !$site->isConnected(); $now = current_time('mysql', true); $patch = ['status' => $newStatus, 'last_check' => $now] + ($firstPass ? ['connected_at' => $now] : []); // Tied to the tested credentials, so a reconnect saved meanwhile keeps its own status. if (!$site->updateIfCredentials($patch, $site->getEncryptedPassword())) { return TargetSite::find($site->getId()) ? $this->claimLost() : $this->siteGone(); } if ($result['success'] && $result['manifest']) { $this->storeManifest($site, $result['manifest']); } if ($firstPass) { (new Logger())->audit('site_added', 'connected site “' . $site->getName() . '”', $site->getId()); } return $this->sendSuccess($result); } /** A wizard that took over an existing site and was cancelled hands its claim back; nothing is deleted. */ public function release(WP_REST_Request $request): WP_REST_Response { $site = TargetSite::find($this->routeId($request, 'id')); if (!$site) { return $this->sendError(__('Site not found.', 'publishdeck'), 404, 'fspd_not_found'); } if (!$site->holdsClaim((string) $request->get_param('claim'))) { return $this->claimLost(); } $site->releaseClaim(); return $this->sendSuccess(null, 204); } public function manifestRefresh(WP_REST_Request $request): WP_REST_Response { $site = TargetSite::find($this->routeId($request, 'id')); if (!$site) { return $this->sendError(__('Site not found.', 'publishdeck'), 404, 'fspd_not_found'); } // Undecryptable credentials are not flagged here; passwordFor() flags only an http:// site. try { $password = $this->credentials()->passwordFor($site); } catch (CredentialsDecryptException $exception) { return $this->sendError($exception->localizedCause(), 422, $exception->errorCode()); } $manifest = $this->connector()->fetchManifest($site->apiBase(), $site->getUsername(), $password); // Null means block-types was unreachable, so the last-good manifest stays. if ($manifest === null) { return $this->sendError( __('Could not read the block list from this site; the last known manifest was kept.', 'publishdeck'), 502, 'fspd_manifest_unreachable' ); } $this->storeManifest($site, $manifest); return $this->sendSuccess(['refreshed' => true]); } private function storeManifest(TargetSite $site, array $manifest): void { SiteManifest::upsert( $site->getId(), $manifest['blocks'], $manifest['post_types'], $manifest['taxonomies'], $manifest['formats'] ?? null ); } }