sendError(__('Forbidden.', 'publishdeck'), 403, 'rest_forbidden'); } return $this->sendSuccess($this->listPayload($request)); } /** GET /author/{id} — the per-author dashboard; admins are viewable but read-only. */ public function show(WP_REST_Request $request): WP_REST_Response { if (!current_user_can('manage_options')) { return $this->sendError(__('Forbidden.', 'publishdeck'), 403, 'rest_forbidden'); } $userId = $this->routeId($request, 'user_id'); if ($userId <= 0 || !user_can($userId, 'edit_fspd_posts')) { return $this->sendError(__('Author not found.', 'publishdeck'), 404, 'fspd_invalid_user'); } return $this->sendSuccess($this->detailPayload($userId, user_can($userId, 'manage_options'))); } /** The full per-author dashboard payload, shared by show() and savePermissions(). */ private function detailPayload(int $userId, bool $isAdmin): array { $user = get_userdata($userId); $detail = (new AuthorStats())->build($userId); $isMember = $user && in_array(Capabilities::MEMBER_ROLE, (array) $user->roles, true); $summary = AuthorRole::summarize($userId); $noAccess = $this->noAccess($isMember, $isAdmin, $summary['sites_count']); return [ 'user' => [ 'id' => $userId, 'name' => $user ? $user->display_name : ('#' . $userId), 'email' => $user ? $user->user_email : '', 'avatar_url' => get_avatar_url($userId, ['size' => 96]) ?: '', 'is_admin' => $isAdmin, 'is_member' => $isMember, 'role' => $summary['role'], 'no_access' => $noAccess, 'account_action' => $noAccess ? $this->accountAction($userId) : null, ], 'stats' => $detail['stats'], 'posts_by_site' => $detail['posts_by_site'], 'activity' => $this->mergeActivity($detail['activity'], $userId), 'permissions' => $this->permissionRows($userId, $isAdmin), 'access_updated' => $this->accessUpdated($userId), ]; } /** Whether a member holds no grant on any site while at least one site is connected. */ private function noAccess(bool $isMember, bool $isAdmin, int $sitesCount): bool { return $isMember && !$isAdmin && $sitesCount === 0 && TargetSite::listAll() !== []; } /** * Core's delete screen for the account, or remove-from-site on multisite; null when the viewer may do neither. * * @return array{kind:string,url:string}|null */ private function accountAction(int $userId): ?array { $kind = is_multisite() ? 'remove' : 'delete'; if (!current_user_can($kind === 'remove' ? 'remove_user' : 'delete_user', $userId)) { return null; } $url = add_query_arg( ['action' => $kind, 'user' => $userId, '_wpnonce' => wp_create_nonce('bulk-users')], admin_url('users.php') ); return ['kind' => $kind, 'url' => esc_url_raw($url)]; } /** POST /author/sync — inherits from every site and returns the refreshed list. */ public function sync(WP_REST_Request $request): WP_REST_Response { if (!current_user_can('manage_options')) { return $this->sendError(__('Forbidden.', 'publishdeck'), 403, 'rest_forbidden'); } // Single-flight, so concurrent runs can't lost-update the maps. if (!$this->acquireLock('fspd_author_sync_lock')) { return $this->sendError( __('A permission sync is already running.', 'publishdeck'), 409, 'fspd_sync_in_progress' ); } try { $results = (new AuthorSync())->syncAll(); update_option(self::FLAGS_OPTION, $results, false); } finally { delete_option('fspd_author_sync_lock'); } $count = is_array($results) ? count($results) : 0; (new Logger())->audit('author_sync', 'synced authors from ' . $count . ' ' . ($count === 1 ? 'site' : 'sites')); return $this->sendSuccess($this->listPayload($request)); } /** PUT /author/{user_id}/permissions — one batched save, returning the refreshed rows. */ public function savePermissions(WP_REST_Request $request): WP_REST_Response { if (!current_user_can('manage_options')) { return $this->sendError(__('Forbidden.', 'publishdeck'), 403, 'rest_forbidden'); } $userId = $this->routeId($request, 'user_id'); // Only real hub authors hold grants, and admins are always editors. if (!user_can($userId, 'edit_fspd_posts') || user_can($userId, 'manage_options')) { return $this->sendError(__('Not a manageable author.', 'publishdeck'), 400, 'fspd_invalid_user'); } $changes = $request->get_param('changes'); if (!is_array($changes)) { return $this->sendError(__('No changes provided.', 'publishdeck'), 400, 'fspd_invalid_param'); } // Drop unknown sites; an offline site stays known, so an admin can still revoke a grant there. $knownSiteIds = []; foreach (TargetSite::listAll() as $site) { $knownSiteIds[$site->getId()] = true; } $normalized = []; foreach ($changes as $change) { if (!is_array($change)) { continue; } $siteId = (int) ($change['site_id'] ?? 0); if ($siteId <= 0 || !isset($knownSiteIds[$siteId])) { continue; } $level = $change['level'] ?? null; if ($level === null || $level === '' || $level === 'inherited' || $level === 'reset') { $level = null; } elseif (!in_array($level, self::OVERRIDE_LEVELS, true)) { return $this->sendError(__('Invalid level', 'publishdeck'), 400, 'fspd_invalid_param'); } $normalized[$siteId] = $level; } $applied = (new PermissionStore())->applyOverrides($userId, $normalized); if ($applied > 0) { update_user_meta($userId, self::TOUCH_META, [ 'at' => current_time('mysql', true), 'by' => get_current_user_id(), ]); $user = get_userdata($userId); $userName = $user ? $user->display_name : ('#' . $userId); (new Logger())->audit( 'permission_set', 'updated ' . $userName . '’s access on ' . $applied . ' ' . ($applied === 1 ? 'site' : 'sites') ); } return $this->sendSuccess($this->detailPayload($userId, false)); } /** POST /author/users — creates a hub account for a pending remote author. */ public function createUser(WP_REST_Request $request): WP_REST_Response { if (!current_user_can('manage_options') || !current_user_can('create_users')) { return $this->sendError(__('Forbidden.', 'publishdeck'), 403, 'rest_forbidden'); } $email = $this->normalizeEmail($request->get_param('email')); $name = trim((string) $request->get_param('name')); $password = (string) $request->get_param('password'); if (!is_email($email)) { return $this->sendError( __('A valid email is required.', 'publishdeck'), 400, 'fspd_invalid_email' ); } if ($password !== '' && strlen($password) < 8) { return $this->sendError( __('The password must be at least 8 characters.', 'publishdeck'), 400, 'fspd_weak_password' ); } // Single-flight per email: user_email is not unique and wp_insert_user's check races. $lock = 'fspd_create_user_' . md5($email); if (!$this->acquireLock($lock)) { return $this->sendError( __('That user is already being added.', 'publishdeck'), 409, 'fspd_create_in_progress' ); } try { $existing = get_user_by('email', $email); if ($existing instanceof \WP_User) { // Resume a create that died before inheritance rather than dead-ending on a 409. if (!empty((new PermissionStore())->get((int) $existing->ID))) { return $this->sendError( __('A hub user with that email already exists.', 'publishdeck'), 409, 'fspd_user_exists' ); } if (!$request->get_param('adopt_existing')) { $message = __( 'A hub user with that email already exists. Re-send with adopt_existing to grant them access.', 'publishdeck' ); return $this->sendError($message, 409, 'fspd_user_exists'); } $this->inheritFromLastSync((int) $existing->ID, $email); return $this->sendSuccess($this->listPayload($request)); } // A child's claim never picks a hub role; a member's reach comes from per-site levels. Capabilities::ensureSeeded($this->capabilityType()); $userId = wp_insert_user([ 'user_login' => $this->uniqueLogin($email), 'user_email' => $email, 'display_name' => $name !== '' ? $name : $email, 'user_pass' => $password !== '' ? $password : wp_generate_password(24, true, true), 'role' => Capabilities::MEMBER_ROLE, ]); if (is_wp_error($userId)) { return $this->sendError($userId->get_error_message(), 400, 'fspd_user_create_failed'); } $this->inheritFromLastSync((int) $userId, $email); // With no inline password, email a set-your-own link; never email a password. if ($password === '' && function_exists('wp_send_new_user_notifications')) { wp_send_new_user_notifications((int) $userId, 'user'); } (new Logger())->audit('user_added', 'added ' . $email . ' to the hub'); return $this->sendSuccess($this->listPayload($request)); } finally { delete_option($lock); } } /** Derives a unique WP login from the email local-part, suffixing on collision. */ private function uniqueLogin(string $email): string { $base = sanitize_user(current(explode('@', $email)), true); if ($base === '') { $base = 'author'; } $login = $base; $suffix = 1; while (username_exists($login)) { $login = $base . $suffix; $suffix++; } return $login; } /** Acquires a single-flight lock, taking over a crashed one via CAS; release with delete_option. */ private function acquireLock(string $name, int $ttl = 300): bool { // A 'notoptions' entry makes add_option() skip its existence check and overwrite a live lock. $notoptions = wp_cache_get('notoptions', 'options'); if (is_array($notoptions) && isset($notoptions[$name])) { unset($notoptions[$name]); wp_cache_set('notoptions', $notoptions, 'options'); } if (add_option($name, (string) time(), '', 'no')) { return true; } // The CAS writes behind core's cache, and a stale cached value would make the lock untakeable. wp_cache_delete($name, 'options'); $lockedAt = (int) get_option($name); if (time() - $lockedAt < $ttl) { return false; } global $wpdb; // A compare-and-set on the option row; update_option cannot express "only if unchanged". // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- CAS on one option, no core API. $took = $wpdb->query($wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s", (string) time(), $name, (string) $lockedAt )); // Won or not, the row may have moved, so the next reader must see what is stored. wp_cache_delete($name, 'options'); return (int) $took === 1; } /** The capability_type from config. */ private function capabilityType() { $config = is_readable(FSPD_PATH . 'config/app.php') ? require FSPD_PATH . 'config/app.php' : []; return $config['capability_type'] ?? ['fspd_post', 'fspd_posts']; } private function syncSummary(): array { $summary = get_option(self::FLAGS_OPTION, []); return is_array($summary) ? $summary : []; } private function normalizeEmail($email): string { return strtolower(trim((string) $email)); } /** Inherits a new user's per-site levels from the last sync, with no re-fetch. */ private function inheritFromLastSync(int $userId, string $email): void { $store = new PermissionStore(); foreach ($this->syncSummary() as $site) { if (!is_array($site)) { continue; } $siteId = (int) ($site['site_id'] ?? 0); // Discovery results outlive a removed site, so never grant on one that has gone. if ($siteId <= 0 || !TargetSite::find($siteId)) { continue; } foreach ((array) ($site['unmatched'] ?? []) as $person) { if ($this->normalizeEmail($person['email'] ?? '') !== $email) { continue; } $level = (string) ($person['level'] ?? ''); if (Access::grants($level)) { $store->setInherited($userId, $siteId, $level); } } } } /** One page of hub authors plus the pending remotes; shared by index(), sync() and createUser(). */ private function listPayload(?WP_REST_Request $request = null): array { $search = $request ? sanitize_text_field((string) $request->get_param('search')) : ''; $role = $request ? sanitize_text_field((string) $request->get_param('role')) : ''; $page = $request ? max(1, (int) $request->get_param('page')) : 1; $perPage = $request ? (int) $request->get_param('per_page') : 10; $perPage = max(1, min(100, $perPage ?: 10)); $allUsers = $this->listUsers($search); // Unfiltered, so the screen can tell an empty hub from no matches; only a search narrows $allUsers. $totalAll = $search === '' ? count($allUsers) : $this->authorCount(); // The role is derived from grants, not stored, so it is filtered here rather than in the query. $roles = array_values(array_unique(array_filter(array_column($allUsers, 'role')))); $filtered = $role === '' ? $allUsers : array_values(array_filter($allUsers, static fn($user) => $user['role'] === $role)); $total = count($filtered); $totalPages = (int) ceil($total / $perPage); $page = $totalPages > 0 ? min($page, $totalPages) : 1; return [ 'users' => array_slice($filtered, ($page - 1) * $perPage, $perPage), 'pending' => $this->pendingList(), 'roles' => $roles, 'total' => $total, 'total_all' => $totalAll, 'page' => $page, 'per_page' => $perPage, 'total_pages' => $totalPages, ]; } /** Hub authors enriched with avatar, effective role and reachable-site count. */ private function listUsers(string $search = ''): array { // Hoist the site count and share one store, so the role derivation is not a per-author query. $activeSiteCount = count(TargetSite::active()); $store = new PermissionStore(); $authors = $this->authors($search); PermissionStore::primeFor(array_map(static fn($author) => (int) $author['id'], $authors)); $users = []; foreach ($authors as $author) { $summary = AuthorRole::fromMap( $store->get((int) $author['id']), (bool) $author['is_admin'], $activeSiteCount, $author['role'] === Capabilities::MEMBER_ROLE ); $users[] = [ 'id' => $author['id'], 'name' => $author['name'], 'email' => $author['email'], 'avatar_url' => get_avatar_url((int) $author['id'], ['size' => 40]) ?: '', 'is_admin' => $author['is_admin'], 'role' => $summary['role'], 'sites_count' => $summary['sites_count'], ]; } return $users; } /** Pending remotes (unmatched authors), with the level each site would give them on the hub. */ private function pendingList(): array { $names = []; foreach (TargetSite::listAll() as $site) { $names[$site->getId()] = $site->getName(); } $pending = []; foreach ($this->flags()['unmatched'] as $person) { $levelMap = []; $sites = []; foreach ((array) ($person['sites'] ?? []) as $siteId => $level) { $levelMap[$siteId] = ['level' => $level]; $sites[] = ['site_id' => (int) $siteId, 'name' => $names[$siteId] ?? '', 'level' => $level]; } $summary = AuthorRole::fromMap($levelMap, false, 0); $pending[] = [ 'name' => $person['name'] !== '' ? $person['name'] : $person['email'], 'email' => $person['email'], 'role' => $summary['role'], 'sites_count' => $summary['sites_count'], 'sites' => $sites, ]; } return $pending; } /** Every active site with the author's effective level/source + unreadable flag. */ private function permissionRows(int $userId, bool $isAdmin): array { $grants = (new PermissionStore())->get($userId); $unreadable = $this->flags()['unreadable_sites']; $rows = []; foreach (TargetSite::active() as $site) { $siteId = $site->getId(); $cell = $grants[$siteId] ?? null; $rows[] = [ 'site_id' => $siteId, 'name' => $site->getName(), 'status' => $site->getStatus(), 'level' => $isAdmin ? Access::EDITOR : (string) ($cell['level'] ?? Access::NONE), 'source' => $isAdmin ? 'admin' : (string) ($cell['source'] ?? 'inherited'), // What clearing an override hands back: the level the site last gave, or nothing. 'inherited_level' => $isAdmin ? '' : (string) ($cell['inherited_level'] ?? ''), 'unreadable' => in_array($siteId, $unreadable, true), ]; } return $rows; } /** Merges the "access last updated" line into the sync feed, newest first. */ private function mergeActivity(array $sync, int $userId): array { $access = $this->accessUpdated($userId); if ($access) { $sync[] = ['kind' => 'access', 'at' => $access['at'], 'by' => $access['by_name']]; } usort($sync, static fn($left, $right) => strcmp((string) $right['at'], (string) $left['at'])); return array_slice($sync, 0, 8); } /** The touch meta, or null when access was never changed by an admin. */ private function accessUpdated(int $userId): ?array { $touch = get_user_meta($userId, self::TOUCH_META, true); if (!is_array($touch) || empty($touch['at'])) { return null; } $changedBy = (int) ($touch['by'] ?? 0); $user = $changedBy > 0 ? get_userdata($changedBy) : null; return [ 'by' => $changedBy, 'by_name' => $user ? $user->display_name : __('an admin', 'publishdeck'), 'at' => (string) $touch['at'], ]; } /** Every hub author, ignoring the screen's filters; ids only, so it stays a light query. */ private function authorCount(): int { return count(get_users(['capability' => 'edit_fspd_posts', 'fields' => 'ID'])); } /** Hub users who can author remote posts, optionally narrowed by the screen's search. */ private function authors(string $search = ''): array { $args = [ 'capability' => 'edit_fspd_posts', 'fields' => ['ID', 'display_name', 'user_email'], ]; if ($search !== '') { $args['search'] = '*' . $search . '*'; $args['search_columns'] = ['display_name', 'user_email', 'user_login', 'user_nicename']; } $users = get_users($args); // Prime caps and meta in one query, so the lookups below are cache hits, not N+1. cache_users(array_map(static fn($user) => (int) $user->ID, $users)); return array_map(static function ($user) { $id = (int) $user->ID; $wpUser = get_userdata($id); // cache hit after cache_users() // array_values: core's role list can keep the keys of roles it dropped, so index 0 may be absent. $roles = $wpUser ? array_values((array) $wpUser->roles) : []; $role = (string) ($roles[0] ?? ''); return [ 'id' => $id, 'name' => $user->display_name, 'email' => $user->user_email, 'is_admin' => user_can($id, 'manage_options'), 'role' => $role, ]; }, $users); } /** UI flags from the last-sync summary: unreadable sites, and the unmatched remote users. */ private function flags(): array { if ($this->flagsCache !== null) { return $this->flagsCache; } $unreadable = []; $unmatched = []; foreach ($this->syncSummary() as $site) { if (!is_array($site)) { continue; } $siteId = (int) ($site['site_id'] ?? 0); if (empty($site['can_read_users'])) { $unreadable[] = $siteId; } foreach ((array) ($site['unmatched'] ?? []) as $person) { $email = $this->normalizeEmail($person['email'] ?? ''); if ($email === '') { continue; } if (!isset($unmatched[$email])) { $unmatched[$email] = [ 'name' => (string) ($person['name'] ?? ''), 'email' => (string) ($person['email'] ?? ''), 'sites' => [], ]; } $level = (string) ($person['level'] ?? ''); if ($siteId > 0 && Access::grants($level)) { $unmatched[$email]['sites'][$siteId] = $level; } } } // Anyone who now has a hub account is no longer pending; one lookup for the whole set. $existing = $this->existingHubEmails(array_keys($unmatched)); $unmatched = array_filter($unmatched, static function ($person) use ($existing) { return !isset($existing[strtolower((string) $person['email'])]); }); $this->flagsCache = [ 'unreadable_sites' => array_values($unreadable), 'unmatched' => array_values($unmatched), ]; return $this->flagsCache; } /** * Which of the given emails already belong to a hub user, as an email => true set. * * @param array $emails lower-cased email addresses * @return array */ private function existingHubEmails(array $emails): array { $emails = array_values(array_unique(array_filter($emails, static fn($email) => $email !== ''))); if (empty($emails)) { return []; } global $wpdb; // user_email collates case-insensitively, so no per-row LOWER(): that would scan the index. // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one read for many emails, no core API. $rows = $wpdb->get_col($wpdb->prepare( "SELECT user_email FROM {$wpdb->users} WHERE user_email IN (" . implode(',', array_fill(0, count($emails), '%s')) . ")", ...$emails )); $present = []; foreach ($rows ?: [] as $email) { $present[strtolower((string) $email)] = true; } return $present; } }