| @@ -26,12 +26,44 @@ | ||
| 26 | 26 | /** |
| 27 | 27 | * WordPress capabilities integration. |
| 28 | 28 | */ |
| 29 | 29 | class Groups_WordPress { |
| 30 | - | |
| 30 | + | |
| 31 | 31 | /** |
| 32 | + * Cache group | |
| 33 | + * | |
| 34 | + * @var string | |
| 35 | + */ | |
| 36 | + const CACHE_GROUP = 'groups'; | |
| 37 | + | |
| 38 | + /** | |
| 39 | + * Cache key prefix | |
| 40 | + * | |
| 41 | + * @var string | |
| 42 | + */ | |
| 43 | + const HAS_CAP = 'has_cap'; | |
| 44 | + | |
| 45 | + /** | |
| 46 | + * Filter priority: groups_user_can | |
| 47 | + * | |
| 48 | + * @var integer | |
| 49 | + * | |
| 50 | + * @since 2.11.0 | |
| 51 | + */ | |
| 52 | + const GROUPS_USER_CAN_FILTER_PRIORITY = 10; | |
| 53 | + | |
| 54 | + /** | |
| 55 | + * Filter priority: user_has_cap | |
| 56 | + * | |
| 57 | + * @var int | |
| 58 | + * | |
| 59 | + * @since 2.11.0 | |
| 60 | + */ | |
| 61 | + const USER_HAS_CAP_FILTER_PRIORITY = PHP_INT_MAX; | |
| 62 | + | |
| 63 | + /** | |
| 32 | 64 | * Hook into actions to extend user capabilities. |
| 33 | - * | |
| 65 | + * | |
| 34 | 66 | * @todo We might want to keep up with new capabilities when added, so |
| 35 | 67 | * that others don't have to add these explicitly to Groups when they |
| 36 | 68 | * add them to WordPress. Currently there's no hook for when a capability |
| 37 | 69 | * is added and checking this in any other way is too costly. |
| @@ -36,61 +68,203 @@ | ||
| 36 | 68 | * add them to WordPress. Currently there's no hook for when a capability |
| 37 | 69 | * is added and checking this in any other way is too costly. |
| 38 | 70 | */ |
| 39 | 71 | public static function init() { |
| 40 | - // args: string $result, Groups_User $groups_user, string $capability | |
| 41 | - add_filter( 'groups_user_can', array( __CLASS__, 'groups_user_can' ), 10, 3 ); | |
| 42 | - add_filter( 'user_has_cap', array( __CLASS__, 'user_has_cap' ), 10, 3 ); | |
| 72 | + // args: boolean $result, Groups_User $groups_user, string $capability | |
| 73 | + add_filter( 'groups_user_can', array( __CLASS__, 'groups_user_can' ), self::GROUPS_USER_CAN_FILTER_PRIORITY, 5 ); | |
| 74 | + add_filter( 'user_has_cap', array( __CLASS__, 'user_has_cap' ), self::USER_HAS_CAP_FILTER_PRIORITY, 4 ); | |
| 43 | 75 | } |
| 44 | - | |
| 76 | + | |
| 45 | 77 | /** |
| 78 | + * Whether the given user has the capability. | |
| 79 | + * | |
| 80 | + * Calls Groups_User::user_can() with user_has_cap and groups_user_can filters temporarily removed. | |
| 81 | + * | |
| 82 | + * @since 3.1.0 | |
| 83 | + * | |
| 84 | + * @param int|WP_User $user user ID or object | |
| 85 | + * @param string $capability capability name | |
| 86 | + * @param mixed ...$args optional parameters, typically an object ID | |
| 87 | + * | |
| 88 | + * @return boolean | |
| 89 | + */ | |
| 90 | + private static function unfiltered_user_can( $user, $capability, ...$args ) { | |
| 91 | + // We want to check without the capabilities granted to the user via groups. | |
| 92 | + // Groups_User::user_can() relies on user_can() or WP_User->has_cap() in the absense of the former, | |
| 93 | + // which will cause our user_has_cap filter to act so we have to remove it temporarily. | |
| 94 | + // To avoid potential internal conflicts or cicular dependencies, we also remove the groups_user_can filter which | |
| 95 | + // relies on this method. | |
| 96 | + $result = false; | |
| 97 | + $filter_user_has_cap = remove_filter( 'user_has_cap', array( __CLASS__, 'user_has_cap' ), self::USER_HAS_CAP_FILTER_PRIORITY ); // @since 3.1.0 | |
| 98 | + $filter_groups_user_can = remove_filter( 'groups_user_can', array( __CLASS__, 'groups_user_can' ), self::GROUPS_USER_CAN_FILTER_PRIORITY ); // @since 3.1.0 | |
| 99 | + $result = Groups_User::user_can( $user, $capability, ...$args ); | |
| 100 | + if ( $filter_user_has_cap ) { | |
| 101 | + add_filter( 'user_has_cap', array( __CLASS__, 'user_has_cap' ), self::USER_HAS_CAP_FILTER_PRIORITY, 4 ); | |
| 102 | + } | |
| 103 | + if ( $filter_groups_user_can ) { | |
| 104 | + add_filter( 'groups_user_can', array( __CLASS__, 'groups_user_can' ), self::GROUPS_USER_CAN_FILTER_PRIORITY, 5 ); | |
| 105 | + } | |
| 106 | + return $result; | |
| 107 | + } | |
| 108 | + | |
| 109 | + /** | |
| 46 | 110 | * Extends Groups user capability with its WP_User capability. |
| 47 | - * | |
| 48 | - * @param string $result | |
| 111 | + * | |
| 112 | + * @param boolean $result | |
| 49 | 113 | * @param Groups_User $groups_user |
| 50 | 114 | * @param string $capability |
| 115 | + * @param mixed $object | |
| 116 | + * @param mixed $args | |
| 117 | + * | |
| 118 | + * @return boolean | |
| 51 | 119 | */ |
| 52 | - public static function groups_user_can( $result, $groups_user, $capability ) { | |
| 120 | + public static function groups_user_can( $result, $groups_user, $capability, $object, $args ) { | |
| 121 | + // The intention here is to complement capabilities from groups with those of the user. | |
| 122 | + // Our user_has_cap filter extends user capabilities with those from groups, i.e. the reverse complementary. | |
| 123 | + // Thus, our user_has_cap filter should not be involved here. Also, we want to avoid any potential circular | |
| 124 | + // dependency which could arise from having our groups_user_can fired again while we attend to that action here. | |
| 125 | + // To avoid any potential hickups, we also remove the filter while we handle it. | |
| 126 | + $filter_groups_user_can = remove_filter( 'groups_user_can', array( __CLASS__, 'groups_user_can' ), self::GROUPS_USER_CAN_FILTER_PRIORITY ); // @since 3.1.0 | |
| 53 | 127 | if ( !$result ) { |
| 54 | 128 | // Check if the capability exists, otherwise this will |
| 55 | 129 | // produce a deprecation warning "Usage of user levels by plugins |
| 56 | 130 | // and themes is deprecated", not because we actually use a |
| 57 | 131 | // deprecated user level, but because it doesn't exist. |
| 58 | - if ( Groups_Capability::read_by_capability( $capability ) ) { | |
| 59 | - $result = user_can( $groups_user->user->ID, $capability ); | |
| 132 | + if ( | |
| 133 | + !is_numeric( $capability ) || | |
| 134 | + Groups_Capability::read_by_capability( $capability ) | |
| 135 | + ) { | |
| 136 | + if ( $groups_user instanceof Groups_User ) { | |
| 137 | + $user_id = $groups_user->get_user_id(); | |
| 138 | + if ( $user_id !== null ) { | |
| 139 | + | |
| 140 | + // reduce to remnant args; we have $args[0] as $capability, $args[1] as $user_id and $args[2] as $object and pass them along explicitly | |
| 141 | + if ( is_array( $args ) ) { | |
| 142 | + array_shift( $args ); // $capability <-> user_has_cap filter $args[0] -> requested capability | |
| 143 | + array_shift( $args ); // $user_id <-> user_has_cap filter $args[1] -> concerned user ID | |
| 144 | + array_shift( $args ); // $object <-> user_has_cap filter $args[2] -> typically object ID | |
| 145 | + } else if ( $args instanceof Traversable ) { | |
| 146 | + $pass_args = array(); | |
| 147 | + $i = 0; | |
| 148 | + foreach ( $args as $arg ) { | |
| 149 | + if ( $i > 2 ) { | |
| 150 | + $pass_args[] = $arg; | |
| 151 | + } | |
| 152 | + } | |
| 153 | + $args = $pass_args; | |
| 154 | + } | |
| 155 | + | |
| 156 | + if ( $object === null ) { | |
| 157 | + $result = self::unfiltered_user_can( $user_id, $capability ); | |
| 158 | + } else { | |
| 159 | + // @since 3.0.0 | |
| 160 | + $object_id = null; | |
| 161 | + if ( is_numeric( $object ) ) { | |
| 162 | + $object_id = Groups_Utility::id( $object ); | |
| 163 | + if ( $object_id === false ) { | |
| 164 | + $object_id = null; | |
| 165 | + } | |
| 166 | + } else if ( is_object( $object ) && method_exists( $object, 'get_id' ) ) { | |
| 167 | + $object_id = $object->get_id(); | |
| 168 | + } | |
| 169 | + // @since 4.0.0 make sure that $args can be unpacked | |
| 170 | + if ( !( is_array( $args ) || is_object( $args ) && $args instanceof Traversable ) ) { | |
| 171 | + $args = array(); | |
| 172 | + } | |
| 173 | + // PHP >= 8.1.0 named arguments can be used after unpacking ...$args | |
| 174 | + // With prior PHP versions, the order in which values appear in an $args array | |
| 175 | + // determines the order of the parameters passed. | |
| 176 | + if ( $object_id !== null ) { | |
| 177 | + $result = self::unfiltered_user_can( $user_id, $capability, $object_id, ...$args ); | |
| 178 | + } else { | |
| 179 | + $result = self::unfiltered_user_can( $user_id, $capability, $object, ...$args ); | |
| 180 | + } | |
| 181 | + } | |
| 182 | + } | |
| 183 | + } | |
| 60 | 184 | } |
| 61 | 185 | } |
| 186 | + if ( $filter_groups_user_can ) { | |
| 187 | + add_filter( 'groups_user_can', array( __CLASS__, 'groups_user_can' ), self::GROUPS_USER_CAN_FILTER_PRIORITY, 5 ); // @since 3.1.0 | |
| 188 | + } | |
| 62 | 189 | return $result; |
| 63 | 190 | } |
| 64 | - | |
| 191 | + | |
| 65 | 192 | /** |
| 66 | 193 | * Extend user capabilities with Groups user capabilities. |
| 67 | - * | |
| 68 | - * @param array $allcaps the capabilities the user has | |
| 69 | - * @param array $caps the requested capabilities | |
| 70 | - * @param array $args capability context which can provide the requested capability as $args[0], the user ID as $args[1] and the related object's ID as $args[2] | |
| 194 | + * | |
| 195 | + * Hooked on the user_has_cap filter. See WP_User::has_cap(). | |
| 196 | + * | |
| 197 | + * @see user_can() | |
| 198 | + * @see WP_User::has_cap() | |
| 199 | + * | |
| 200 | + * @param array $allcaps capability names mapped to boolean values representing whether the user has the capability | |
| 201 | + * @param array $caps required primitive capabilities for the requested capability | |
| 202 | + * @param array $args the requested capability in $args[0], the user ID in $args[1] and optionally the related object's ID in $args[2] and potentially further parameters in $args[3] and so on ... | |
| 203 | + * @param WP_User $user the user object | |
| 204 | + * | |
| 205 | + * @return array | |
| 71 | 206 | */ |
| 72 | - public static function user_has_cap( $allcaps, $caps, $args ) { | |
| 73 | - $user_id = isset( $args[1] ) ? $args[1] : null; | |
| 74 | - $groups_user = new Groups_User( $user_id ); | |
| 207 | + public static function user_has_cap( $allcaps, $caps, $args, $user ) { | |
| 75 | 208 | if ( is_array( $caps ) ) { |
| 76 | - // we need to deactivate this because invoking $groups_user->can() | |
| 77 | - // would trigger this same function and we would end up | |
| 78 | - // in an infinite loop | |
| 79 | - remove_filter( 'user_has_cap', array( __CLASS__, 'user_has_cap' ), 10, 3 ); | |
| 80 | - foreach( $caps as $cap ) { | |
| 81 | - if ( $groups_user->can( $cap ) ) { | |
| 82 | - $allcaps[$cap] = true; | |
| 209 | + $user_id = 0; | |
| 210 | + if ( isset( $user->ID ) ) { | |
| 211 | + $user_id = intval( $user->ID ); | |
| 212 | + } else if ( isset( $args[1] ) ) { | |
| 213 | + $user_id = intval( $args[1] ); | |
| 214 | + } | |
| 215 | + $hash = md5( json_encode( $caps ) . json_encode( $args ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode | |
| 216 | + $cached = Groups_Cache::get( self::HAS_CAP . '_' . $user_id . '_' . $hash, self::CACHE_GROUP ); | |
| 217 | + | |
| 218 | + if ( $cached !== null ) { | |
| 219 | + $_allcaps = $cached->get_value(); | |
| 220 | + unset( $cached ); | |
| 221 | + // Capabilities that were added after our value was cached must be added and | |
| 222 | + // those entries that provide different values must be adopted. This is necessary | |
| 223 | + // because other filters which hook into user_has_cap might have added or modified | |
| 224 | + // things after we had already cached the values. | |
| 225 | + foreach ( $allcaps as $cap => $value ) { | |
| 226 | + if ( | |
| 227 | + !key_exists( $cap, $_allcaps ) || | |
| 228 | + $_allcaps[$cap] !== $value | |
| 229 | + ) { | |
| 230 | + $_allcaps[$cap] = $value; | |
| 231 | + } | |
| 83 | 232 | } |
| 233 | + $allcaps = $_allcaps; | |
| 234 | + } else { | |
| 235 | + $requested_cap = $args[0] ?? ''; // will always be supplied, just in case | |
| 236 | + $object_id = $args[2] ?? null; | |
| 237 | + $groups_user = new Groups_User( $user_id ); | |
| 238 | + // we need to deactivate this because invoking $groups_user->can() | |
| 239 | + // would trigger this same function and we would end up | |
| 240 | + // in an infinite loop | |
| 241 | + remove_filter( 'user_has_cap', array( __CLASS__, 'user_has_cap' ), self::USER_HAS_CAP_FILTER_PRIORITY ); | |
| 242 | + foreach ( $caps as $cap ) { | |
| 243 | + // Check for known primitive capability, if user has it. This requires no parameters from $args. | |
| 244 | + if ( | |
| 245 | + $cap === $requested_cap && $object_id === null || // COND A | |
| 246 | + $cap !== $requested_cap // COND B | |
| 247 | + ) { | |
| 248 | + if ( Groups_Capability::read_by_capability( $cap ) ) { | |
| 249 | + if ( $groups_user->can( $cap ) ) { | |
| 250 | + $allcaps[$cap] = true; | |
| 251 | + } | |
| 252 | + } | |
| 253 | + } | |
| 254 | + } | |
| 255 | + add_filter( 'user_has_cap', array( __CLASS__, 'user_has_cap' ), self::USER_HAS_CAP_FILTER_PRIORITY, 4 ); | |
| 256 | + Groups_Cache::set( self::HAS_CAP . '_' . $user_id . '_' . $hash, $allcaps, self::CACHE_GROUP ); | |
| 84 | 257 | } |
| 85 | - add_filter( 'user_has_cap', array( __CLASS__, 'user_has_cap' ), 10, 3 ); | |
| 86 | 258 | } |
| 259 | + $allcaps = apply_filters( 'groups_user_has_cap', $allcaps, $caps, $args, $user ); | |
| 87 | 260 | return $allcaps; |
| 88 | 261 | } |
| 89 | - | |
| 262 | + | |
| 90 | 263 | /** |
| 91 | 264 | * Adds WordPress capabilities to Groups capabilities. |
| 92 | 265 | * Must be called explicitly. |
| 266 | + * | |
| 93 | 267 | * @see Groups_Controller::activate() |
| 94 | 268 | */ |
| 95 | 269 | public static function activate() { |
| 96 | 270 | self::refresh_capabilities(); |
| @@ -97,8 +271,9 @@ | ||
| 97 | 271 | } |
| 98 | 272 | |
| 99 | 273 | /** |
| 100 | 274 | * Refreshes Groups capabilities based on WordPress capabilities. |
| 275 | + * | |
| 101 | 276 | * @return int number of capabilities added |
| 102 | 277 | */ |
| 103 | 278 | public static function refresh_capabilities() { |
| 104 | 279 | global $wp_roles; |