post_type === self::POST_TYPE ? $post : null; } /** True when a synced entry hasn't been existence-checked within the TTL. */ private function verifyStale(?string $lastVerifiedAt): bool { if (empty($lastVerifiedAt)) { return true; } return (time() - (int) strtotime($lastVerifiedAt)) >= self::VERIFY_TTL; } public function status(WP_REST_Request $request): WP_REST_Response { $postId = $this->routeId($request, 'post_id'); if (!current_user_can('edit_post', $postId)) { return $this->sendError(__('Forbidden.', 'publishdeck'), 403, 'rest_forbidden'); } $registry = $this->registry(); $allEntries = $registry->getAll($postId); // Confirm the remote still exists, throttled, and never let a hiccup 500 the panel. try { $reconciler = new RemoteReconciler(); foreach ($allEntries as $siteId => $entry) { $remoteId = (int) ($entry['remote_post_id'] ?? 0); if ( ($entry['status'] ?? '') === 'synced' && $remoteId > 0 && $this->verifyStale($entry['last_verified_at'] ?? null) ) { $reconciler->verifyEntry($postId, (int) $siteId, $remoteId); } } $allEntries = $registry->getAll($postId); // re-read: a check may have flipped a status } catch (\Throwable $error) { $allEntries = $registry->getAll($postId); } $statuses = []; foreach ($allEntries as $siteId => $entry) { $statuses[] = array_merge(['site_id' => $siteId], $entry); } return $this->sendSuccess([ 'post_id' => $postId, 'statuses' => $statuses, ]); } /** Refuses up front when the queue is down, since each route writes its row before it enqueues. */ private function queueUnavailable(): ?WP_REST_Response { if (Scheduler::isAvailable()) { return null; } return $this->sendError( __('The background queue is unavailable, so nothing can be sent right now.', 'publishdeck'), 503, 'fspd_queue_unavailable' ); } public function retry(WP_REST_Request $request): WP_REST_Response { if ($refusal = $this->queueUnavailable()) { return $refusal; } $postId = $this->routeId($request, 'post_id'); if (!current_user_can('edit_post', $postId)) { return $this->sendError(__('Forbidden.', 'publishdeck'), 403, 'rest_forbidden'); } $post = $this->hubPost($postId); if (!$post) { return $this->sendError(__('Post not found.', 'publishdeck'), 404, 'fspd_not_found'); } if ($refusal = $this->refuseInTrash($post->post_status)) { return $refusal; } $registry = $this->registry(); $entry = $registry->get($postId); $siteId = (int) ($entry['site_id'] ?? 0); $status = (string) ($entry['status'] ?? ''); // Only the entry that still matches the post's selected target can be retried. $currentTarget = (int) get_post_meta($postId, TargetResolver::META_KEY, true); if ($currentTarget <= 0) { return $this->notQueued(__('This post has no site to send to.', 'publishdeck'), 409, 'fspd_no_target'); } if ($siteId <= 0) { return $this->notQueued( __('Nothing to retry — this post has not been sent yet.', 'publishdeck'), 409, 'fspd_nothing_to_retry' ); } if ($siteId !== $currentTarget) { return $this->notQueued( __('This post now targets another site. Save it to send it there.', 'publishdeck'), 409, 'fspd_target_changed' ); } // Attribute the retry to the post author, so their per-site levels still gate it. $author = (int) $post->post_author; $actor = ($author > 0 && get_userdata($author)) ? $author : get_current_user_id(); $access = new Access(); $caller = get_current_user_id(); // A refused restore leaves its reason on the 'trashed' row; Retry runs the restore again. if ($status === 'trashed' && !empty($entry['error_code'])) { // Pressing Retry is the explicit approval: it records the caller, which a save never does. $registry->update($postId, $siteId, ['editor_id' => $access->lowerOf($actor, $caller, $siteId)]); (new SyncEngine($registry))->onUntrash($postId); $after = $registry->get($postId, $siteId); if (($after['status'] ?? '') === 'queued') { return $this->sendSuccess(['queued' => 1]); } // Still refused: the restore recorded why. $reason = (string) ($after['error_message'] ?? ''); return $this->notQueued($reason, 409, (string) ($after['error_code'] ?? '')); } // A live claim must never be re-queued, and something already queued will run anyway. if ($status === 'queued' || ($status === 'syncing' && !$registry->isClaimStale($entry))) { return $this->inProgress(); } if ($status !== 'failed' && $status !== 'syncing') { $message = __('Nothing to retry — the last push did not fail.', 'publishdeck'); return $this->notQueued($message, 409, 'fspd_nothing_to_retry'); } // A validation block re-blocks on retry; the row already says what to fix. if (($entry['error_code'] ?? '') === 'validation_blocked') { return $this->notQueued((string) ($entry['error_message'] ?? ''), 409, 'validation_blocked'); } if (!$access->canTarget($actor, $siteId) || !$access->canTarget($caller, $siteId)) { return $this->notQueued( __('You or the post’s author no longer have access to this site.', 'publishdeck'), 403, 'fspd_forbidden' ); } // An explicit send approves what is owed, at the lower level; the worker guards a live copy. $pushActor = $access->lowerOf($actor, $caller, $siteId); $registry->update($postId, $siteId, ['editor_id' => $pushActor]); $registry->markQueued($postId, $siteId, $pushActor); Scheduler::enqueuePush($postId, $siteId, 0); return $this->sendSuccess(['queued' => 1]); } private function inProgress(): WP_REST_Response { return $this->notQueued(__('This post is already being sent.', 'publishdeck'), 409, 'fspd_push_in_progress'); } /** An error saying why nothing was queued, with a plain fallback for an empty reason. */ private function notQueued(string $message, int $status, string $code): WP_REST_Response { if ($message === '') { $message = __('Nothing was sent.', 'publishdeck'); } return $this->sendError($message, $status, $code !== '' ? $code : 'fspd_not_queued'); } public function pullFromSite(WP_REST_Request $request): WP_REST_Response { if ($refusal = $this->queueUnavailable()) { return $refusal; } $postId = $this->routeId($request, 'post_id'); if (!current_user_can('edit_post', $postId)) { return $this->sendError(__('Forbidden.', 'publishdeck'), 403, 'rest_forbidden'); } $post = $this->hubPost($postId); if (!$post) { return $this->sendError(__('Post not found.', 'publishdeck'), 404, 'fspd_not_found'); } // A pull would write the child's status over the Trash; Pull all skips the Trash too. if ($refusal = $this->refuseInTrash($post->post_status)) { return $refusal; } $registry = $this->registry(); $entry = $registry->get($postId); $siteId = (int) ($entry['site_id'] ?? 0); $remotePostId = (int) ($entry['remote_post_id'] ?? 0); if (!$siteId || !$remotePostId) { return $this->sendError( __('No remote copy to pull — publish to a site first.', 'publishdeck'), 400, 'fspd_no_remote' ); } if (!(new Access())->canTarget(get_current_user_id(), $siteId)) { return $this->sendError(__('You do not have access to this site.', 'publishdeck'), 403, 'rest_forbidden'); } $status = $entry['status'] ?? ''; if ($status === 'removed' || $status === 'trashed') { return $this->sendError( __('Post is not on the target site.', 'publishdeck'), 400, 'fspd_no_remote' ); } if ($status === 'queued' || $status === 'syncing') { return $this->sendError( __('A push is in progress — try again once it completes.', 'publishdeck'), 409, 'fspd_push_in_progress' ); } // Claiming is the check: a racing request loses the row update and gets the 409. if (!$registry->markPulling($postId, $siteId)) { return $this->sendError( __('A pull is already in progress.', 'publishdeck'), 409, 'fspd_pull_in_progress' ); } Scheduler::enqueuePull($postId, $siteId, $remotePostId, (bool) $request->get_param('force')); return $this->sendSuccess(['queued' => 1]); } /** POST /pull-all — a bounded, non-force pull of every linked post, so conflicts still flag. */ public function pullAll(WP_REST_Request $request): WP_REST_Response { if ($refusal = $this->queueUnavailable()) { return $refusal; } $limit = 200; $postIds = Registry::linkedPostIds( self::POST_TYPE, ['publish', 'draft', 'pending', 'future', 'private'], $limit ); // One read each for state and posts, so the loop below never queries per post. Registry::primeFor($postIds); if (!empty($postIds)) { _prime_post_caches($postIds, false, false); } $registry = $this->registry(); $access = new Access(); $queued = 0; $skipped = 0; foreach ($postIds as $postId) { if (!current_user_can('edit_post', $postId)) { $skipped++; continue; } $entry = $registry->get($postId); $siteId = (int) ($entry['site_id'] ?? 0); $remotePostId = (int) ($entry['remote_post_id'] ?? 0); $status = $entry['status'] ?? ''; // Skip anything without a live remote copy, or with a push in flight. if ( !$siteId || !$remotePostId || in_array($status, ['removed', 'trashed', 'queued', 'syncing'], true) ) { $skipped++; continue; } // Skip a post already being pulled, unless its claim went stale. if (($entry['pull_status'] ?? null) === 'pulling' && !$registry->isPullClaimStale($entry)) { $skipped++; continue; } // Skip sites the caller has no per-site grant on. if (!$access->canTarget(get_current_user_id(), $siteId)) { $skipped++; continue; } if (!$registry->markPulling($postId, $siteId)) { $skipped++; continue; } Scheduler::enqueuePull($postId, $siteId, $remotePostId, false); $queued++; } return $this->sendSuccess([ 'queued' => $queued, 'skipped' => $skipped, // A full batch means the cap was reached, so more may be waiting for the next run. 'capped' => count($postIds) >= $limit, ]); } public function removeFromSite(WP_REST_Request $request): WP_REST_Response { if ($refusal = $this->queueUnavailable()) { return $refusal; } $postId = $this->routeId($request, 'post_id'); if (!current_user_can('edit_post', $postId)) { return $this->sendError(__('Forbidden.', 'publishdeck'), 403, 'rest_forbidden'); } $post = $this->hubPost($postId); if (!$post) { return $this->sendError(__('Post not found.', 'publishdeck'), 404, 'fspd_not_found'); } if ($refusal = $this->refuseInTrash($post->post_status)) { return $refusal; } $registry = $this->registry(); $entry = $registry->get($postId); $siteId = (int) ($entry['site_id'] ?? 0); $remotePostId = (int) ($entry['remote_post_id'] ?? 0); if ($siteId > 0 && !(new Access())->canTarget(get_current_user_id(), $siteId)) { return $this->sendError(__('You do not have access to this site.', 'publishdeck'), 403, 'rest_forbidden'); } // The child is asked, since the hub's reading of a live copy can be hours old. $refusal = $siteId > 0 ? $this->liveCopyGuard($registry)->refusal(get_current_user_id(), $postId, $siteId, $entry) : null; if ($refusal === LiveCopyGuard::LIVE) { return $this->sendError( __('This post is live on that site, and removing it needs publish access there.', 'publishdeck'), 403, 'fspd_write_level_live_copy' ); } if ($refusal === LiveCopyGuard::UNCONFIRMED) { return $this->sendError( __('The site could not confirm its copy is still a draft, so it was not removed.', 'publishdeck'), 409, 'fspd_write_level_live_copy' ); } if ($remotePostId && $siteId) { // Refuse now rather than unlink a copy the job could never delete without the password. $failure = (new Credentials())->failureForSite($siteId); if ($failure) { return $this->sendError($failure->localizedCause(), 422, $failure->errorCode()); } Scheduler::enqueueRemove($postId, $siteId, $remotePostId); } // Only touch the registry for a committed site, or a phantom site_id=0 entry appears. if ($siteId > 0) { $registry->markRemoved($postId, $siteId); } delete_post_meta($postId, TargetResolver::META_KEY); // So re-selecting a site re-syncs. ContentHasher::clear($postId); return $this->sendSuccess(['removed' => true]); } public function forceSync(WP_REST_Request $request): WP_REST_Response { if ($refusal = $this->queueUnavailable()) { return $refusal; } $postId = $this->routeId($request, 'post_id'); if (!current_user_can('edit_post', $postId)) { return $this->sendError(__('Forbidden.', 'publishdeck'), 403, 'rest_forbidden'); } $post = $this->hubPost($postId); if (!$post) { return $this->sendError(__('Post not found.', 'publishdeck'), 404, 'fspd_not_found'); } if ($refusal = $this->refuseInTrash($post->post_status)) { return $refusal; } $site = (new TargetResolver())->forPost($postId); if (!$site) { return $this->notQueued(__('This post has no site to send to.', 'publishdeck'), 409, 'fspd_no_target'); } $siteId = (int) $site->getId(); // Attribute to the post author, so force-sync respects their per-site levels. $author = (int) $post->post_author; $actor = ($author > 0 && get_userdata($author)) ? $author : get_current_user_id(); $access = new Access(); if (!$access->canTarget($actor, $siteId) || !$access->canTarget(get_current_user_id(), $siteId)) { return $this->notQueued( __('You or the post’s author no longer have access to this site.', 'publishdeck'), 403, 'fspd_forbidden' ); } // Mirror retry()'s guard, scoped to this site so a stale 'removed' entry can't block it. $registry = $this->registry(); $entry = $registry->get($postId); $status = $entry['status'] ?? ''; $entrySiteId = (int) ($entry['site_id'] ?? 0); if ($entrySiteId === $siteId && $status === 'removed') { return $this->notQueued( __('This post was removed from that site. Choose the site again to send it.', 'publishdeck'), 409, 'fspd_removed' ); } if ($entrySiteId === $siteId && $status === 'syncing' && !$registry->isClaimStale($entry)) { return $this->inProgress(); } $refusal = (new SyncEngine($registry))->handleRetarget($postId, $siteId, $actor, get_current_user_id()); if ($refusal) { return $this->sendError( $refusal->get_error_message(), (int) ($refusal->get_error_data()['status'] ?? 409), (string) $refusal->get_error_code() ); } // An explicit send approves what is owed; the worker guards a live copy. $pushActor = $access->lowerOf($actor, get_current_user_id(), $siteId); $registry->update($postId, $siteId, ['editor_id' => $pushActor]); // Cleared only after every check passed, so an unauthorized caller can't force a push. ContentHasher::clear($postId); $registry->markQueued($postId, $siteId, $pushActor); Scheduler::enqueuePush($postId, $siteId, 0); return $this->sendSuccess(['queued' => 1]); } }