optionsRepo = $optionsRepo; $this->functions = $functions; $this->logger = $logger; } /** @return self */ public static function getInstance(): self { if (self::$instance !== null) { return self::$instance; } self::$instance = new self(); return self::$instance; } /** * Test-seam: install an externally-constructed instance, or pass null to * clear the cached singleton. Accepting null keeps a uniform contract with * the other singletons' setInstance() seams (M105 singleton-reset). * * @param self|null $instance * @return void */ public static function setInstance(?self $instance): void { self::$instance = $instance; } /** Test-seam reset. @return void */ public static function reset(): void { self::$instance = null; self::$checkingIsAdmin = false; } /** * Resolve the registered policy and return the current plugin-admin decision. * * Production call sites should use this accessor instead of reading * WordPress capabilities directly. The method fails closed when service * resolution is unavailable and logs the underlying context where possible. * * @return bool */ public static function currentUserCanAccessPluginAdmin(): bool { return ABJ_404_Solution_PluginAdminAuthorization::currentUserCanAccessPluginAdmin(); } /** * Whether the current WP user qualifies as a 404 Solution admin. * * Capability sources, ORed together: * - manage_options or the administrator role * - multisite super admin * - presence in the plugin's plugin_admin_users option (login match) * * The result then runs through the abj404_userIsPluginAdmin filter * so site owners can override either direction. * * Recursion-safe: re-entry returns false to avoid the filter chain * looping back into itself. * * @return bool */ public function isPluginAdmin(): bool { if (self::$checkingIsAdmin) { return false; } self::$checkingIsAdmin = true; try { $options = $this->loadOptions(); $logger = $this->logger !== null ? $this->logger : abj_service('logging'); $logger = is_object($logger) ? $logger : null; $currentUserName = $this->currentUserName(); $capability = $this->currentUserAdminCapability(); $isPluginAdmin = $capability['is_plugin_admin'] || $this->currentUserIsListedPluginAdmin($options, $currentUserName); $filtered = apply_filters('abj404_userIsPluginAdmin', $isPluginAdmin); $this->logPluginAdminDecision( $logger, $options, $currentUserName, $capability['can_manage_options'], $isPluginAdmin, (bool) $filtered ); return (bool) $filtered; } finally { self::$checkingIsAdmin = false; } } /** * @return array */ private function loadOptions(): array { $optionsRepo = $this->optionsRepo !== null ? $this->optionsRepo : abj_service('options_repository'); if (!is_object($optionsRepo) || !method_exists($optionsRepo, 'getOptions')) { return array(); } try { if (method_exists($optionsRepo, 'getPluginAdminUsersOption')) { return array( 'plugin_admin_users' => $optionsRepo->getPluginAdminUsersOption(), ); } $resolvedOptions = $optionsRepo->getOptions(true); return is_array($resolvedOptions) ? $resolvedOptions : array(); } catch (\Throwable $e) { $this->warn('plugin admin option lookup failed (code ' . $e->getCode() . '): ' . $e->getMessage()); return array(); } } /** * @return array{can_manage_options: bool, is_plugin_admin: bool} */ private function currentUserAdminCapability(): array { $canManageOptions = false; $hasAdministratorRole = false; try { $canManageOptions = function_exists('current_user_can') && current_user_can('manage_options'); $hasAdministratorRole = function_exists('current_user_can') && current_user_can('administrator'); } catch (\Throwable $e) { $this->warn('plugin admin capability lookup failed (code ' . $e->getCode() . '): ' . $e->getMessage()); } $isPluginAdmin = $canManageOptions || $hasAdministratorRole; if (function_exists('is_multisite') && is_multisite() && function_exists('is_super_admin') && is_super_admin()) { $isPluginAdmin = true; } return array( 'can_manage_options' => $canManageOptions, 'is_plugin_admin' => $isPluginAdmin, ); } /** @return string|null */ private function currentUserName() { global $current_user; if (!isset($current_user) || !isset($current_user->user_login)) { return null; } return is_string($current_user->user_login) ? $current_user->user_login : null; } /** * @param array $options */ private function currentUserIsListedPluginAdmin(array $options, ?string $currentUserName): bool { if ($currentUserName === null || $currentUserName === '') { return false; } $extraAdmins = isset($options['plugin_admin_users']) ? $options['plugin_admin_users'] : array(); $extraAdmins = $this->normalizeExtraAdmins($extraAdmins); return in_array($currentUserName, $extraAdmins, true); } /** * @param mixed $extraAdmins * @return array */ private function normalizeExtraAdmins($extraAdmins): array { $functions = $this->functions !== null ? $this->functions : abj_service('functions'); if (is_array($extraAdmins)) { if (is_object($functions) && method_exists($functions, 'removeEmptyCustom')) { $extraAdmins = array_filter($extraAdmins, array($functions, 'removeEmptyCustom')); } else { $extraAdmins = array_filter($extraAdmins); } return $this->stringList($extraAdmins); } if (is_string($extraAdmins) && is_object($functions) && method_exists($functions, 'explodeNewline')) { $splitAdmins = $functions->explodeNewline($extraAdmins); return is_array($splitAdmins) ? $this->stringList(array_filter($splitAdmins)) : array(); } return array(); } /** * @param array $values * @return array */ private function stringList(array $values): array { $strings = array(); foreach ($values as $value) { if (is_scalar($value)) { $strings[] = (string) $value; } } return $strings; } /** * @param object|null $logger * @param array $options * @return void */ private function logPluginAdminDecision(?object $logger, array $options, ?string $currentUserName, bool $canManageOptions, bool $isPluginAdmin, bool $filtered): void { if (($filtered && $filtered === $isPluginAdmin) || !is_object($logger) || !method_exists($logger, 'debugMessage')) { return; } $logger->debugMessage( "userIsPluginAdmin detail: result=" . ($filtered ? 'true' : 'false') . ", pre-filter=" . ($isPluginAdmin ? 'true' : 'false') . ", manage_options=" . ($canManageOptions ? 'yes' : 'no') . ", user=" . ($currentUserName !== null ? $currentUserName : '(none)') . ", plugin_admin_users=[" . esc_html($this->extraAdminsSummary($options)) . "]" . ($filtered !== $isPluginAdmin ? ", NOTE: abj404_userIsPluginAdmin filter changed the result" : "") ); } /** * @param array $options */ private function extraAdminsSummary(array $options): string { $rawExtra = isset($options['plugin_admin_users']) ? $options['plugin_admin_users'] : array(); if (is_string($rawExtra)) { return $rawExtra; } if (!is_array($rawExtra)) { return ''; } return implode(', ', $this->normalizeExtraAdmins($rawExtra)); } private function warn(string $message): void { $logger = $this->logger !== null ? $this->logger : (function_exists('abj_service_optional') ? abj_service_optional('logging') : null); if (is_object($logger) && method_exists($logger, 'warn')) { $logger->warn($message); return; } abj404_logPhpFallback('service-resolution-fallback', $message); } /** * `user_has_cap` filter callback: while a plugin admin is viewing one * of the plugin's own admin screens, grant `manage_options` so WP's * capability gate lets them through even if their WP role does not. * * Static because WordPress invokes filter callbacks by name with a * fixed (allcaps, caps, args, user) signature. * * @param array $allcaps * @param array $caps * @param array $args * @param \WP_User $user * @return array */ public static function wpUserHasCapFilter($allcaps, $caps, $args, $user) { if (!is_admin()) { return $allcaps; } try { $policy = abj_service('admin_access_policy'); if (!is_object($policy) || !method_exists($policy, 'isPluginAdmin')) { return $allcaps; } if (!$policy->isPluginAdmin()) { return $allcaps; } if (self::isRequestForPluginAdminPage()) { $allcaps['manage_options'] = true; } } catch (\Throwable $e) { // user_has_cap fires on every current_user_can() check across // all of wp-admin, often many times per page load. A transient // failure resolving this plugin's services must not fatal the // whole admin page for an unrelated capability check; returning // $allcaps unchanged is also the safe failure mode for an // authorization filter (it withholds the extra grant rather // than risking one). if (function_exists('abj404_logRuntimeWarning')) { abj404_logRuntimeWarning('wpUserHasCapFilter failed', $e); } } return $allcaps; } /** * Whether the current request is actually loading THIS plugin's own admin * screen. Scopes the manage_options grant above as narrowly as the * user_has_cap filter allows. * * Binds the two signals WordPress itself uses to route an admin page: * - the `page` query var must EXACTLY equal the plugin page slug * (ABJ404_PP), not merely contain it as a substring, AND * - the current admin file ($pagenow) must be the parent the plugin * registers its menu under: 'admin.php' for the top-level menu * (menuLocation=settingsLevel) or 'options-general.php' for the * Settings submenu (see WordPress_Connector::addMainSettingsPageLink). * * Without both, a delegated plugin admin (a user listed in * plugin_admin_users who lacks manage_options) could decorate any wp-admin * URL with the plugin slug and pick up manage_options request-wide: * e.g. users.php?s=abj404_solution (slug as a substring) or * options.php?page=abj404_solution (right slug, wrong admin file). * * @return bool */ private static function isRequestForPluginAdminPage(): bool { if (self::currentAdminPageQueryVar() !== ABJ404_PP) { return false; } $pagenow = isset($GLOBALS['pagenow']) && is_string($GLOBALS['pagenow']) ? $GLOBALS['pagenow'] : ''; return $pagenow === 'admin.php' || $pagenow === 'options-general.php'; } /** * Resolve the `page` admin query var from the sanitized UserRequest query * string (the same source the filter has always trusted), or null when it * is absent or cannot be determined. * * @return string|null */ private static function currentAdminPageQueryVar(): ?string { $userRequest = ABJ_404_Solution_UserRequest::getInstance(); $queryParts = $userRequest !== null ? $userRequest->getQueryString() : null; if (!is_string($queryParts) || $queryParts === '') { return null; } $parsed = array(); parse_str($queryParts, $parsed); return isset($parsed['page']) && is_string($parsed['page']) ? $parsed['page'] : null; } }