Linux webm002.cluster126.gra.hosting.ovh.net 6.18.39-ovh-vps-grsec-zfs+ #1 SMP PREEMPT_DYNAMIC Tue Jul 21 12:03:15 CEST 2026 x86_64
/
home
/
ariannadhf
/
www
/
wp-content
/
plugins
/
admin-menu-editor
/
modules
/
content-permissions
/
/home/ariannadhf/www/wp-content/plugins/admin-menu-editor/modules/content-permissions/policy.php
<?php namespace YahnisElsts\AdminMenuEditor\ContentPermissions\Policy; use YahnisElsts\AdminMenuEditor\Actors\Actor; class ContentItemPolicy implements \JsonSerializable { const EFFECT_ALLOW = 'allow'; const EFFECT_DENY = 'deny'; private $actorAccess; /** * @var string */ private $replacementContent; /** * @var AccessProtectionSettings */ private $accessProtectionSettings; /** * @var bool */ private $preferAdvancedMode; public function __construct( $actorAccess, $replacementContent = '', ?AccessProtectionSettings $accessProtectionSettings = null, $preferAdvancedMode = false ) { $this->actorAccess = $actorAccess; $this->replacementContent = $replacementContent; $this->preferAdvancedMode = $preferAdvancedMode; if ( $accessProtectionSettings ) { $this->accessProtectionSettings = $accessProtectionSettings; } else { $this->accessProtectionSettings = new AccessProtectionSettings(); } } /** * @param Actor $actor * @param Action $action * @param ContentItemPolicy|null $childObjectPolicy * @param mixed $objectId ID of the object that this policy is evaluated for. Usually a post ID. * @return EvaluationResult|null */ public function evaluate(Actor $actor, Action $action, ?ContentItemPolicy $childObjectPolicy = null, $objectId = null) { $bestMatchEffect = null; $bestMatchPriority = -1; $actionName = $action->getName(); $flattedActors = $actor->flatten(); //flatten() returns actors sorted by priority, from highest to lowest. foreach ($flattedActors as $componentActor) { $actorId = $componentActor->getId(); $priority = $componentActor->getPriority(); //If we already have a match with higher priority than this, we can skip the rest. if ( $bestMatchEffect && ($priority < $bestMatchPriority) ) { break; } if ( isset($this->actorAccess[$actorId][$actionName]) ) { $effect = $this->actorAccess[$actorId][$actionName] ? self::EFFECT_ALLOW : self::EFFECT_DENY; //Prefer "allow" over "deny". An actor with a higher priority could override an "allow" //with a "deny", but if we already got this far, we don't have a match with a higher priority. if ( $effect === self::EFFECT_ALLOW ) { $bestMatchEffect = self::EFFECT_ALLOW; break; } $bestMatchEffect = $effect; $bestMatchPriority = $priority; } } if ( $bestMatchEffect === null ) { return null; } return new EvaluationResult( $bestMatchEffect, $actor, $action, $this, $childObjectPolicy, $objectId ); } public function getReplacementContent() { if ( $this->replacementContent !== '' ) { return $this->replacementContent; } return '<p>' . static::getDefaultReplacementText() . '</p>'; } public static function getDefaultReplacementText() { return _x( "You don't have access to this content.", 'content permissions: default replacement text', 'admin-menu-editor' ); } /** * @return AccessProtection|null */ public function getDirectAccessProtection() { return $this->accessProtectionSettings->getActiveProtection(); } /** * Iterates over all entries in the policy that match one of the given actions and * calls the callback for each entry. * * The callback receives the following parameters: * - string $actorId * - string $actionName * - bool $isAllowed * * @param Action[] $actions * @param callable $callback * @return void */ public function forEachActionEntry($actions, $callback) { if ( !isset($this->actorAccess) ) { return; } foreach ($actions as $action) { $actionName = $action->getName(); foreach ($this->actorAccess as $actorId => $actionSettings) { if ( isset($actionSettings[$actionName]) ) { $callback($actorId, $actionName, $actionSettings[$actionName]); } } } } /** * @param Actor $actor * @param Action $action * @param bool|null $isAllowed * @return void */ public function setActorPermission(Actor $actor, Action $action, $isAllowed) { $actorId = $actor->getId(); $actionName = $action->getName(); if ( $isAllowed === null ) { unset($this->actorAccess[$actorId][$actionName]); if ( empty($this->actorAccess[$actorId]) ) { unset($this->actorAccess[$actorId]); } return; } if ( !isset($this->actorAccess[$actorId]) ) { $this->actorAccess[$actorId] = []; } $this->actorAccess[$actorId][$actionName] = $isAllowed; } /** * Check if the policy has a custom permission setting for the given actor and action. * * @param Actor $actor * @param Action $action * @return bool */ public function hasPermissionSettingFor(Actor $actor, Action $action) { return isset($this->actorAccess[$actor->getId()][$action->getName()]); } /** @noinspection PhpLanguageLevelInspection */ #[\ReturnTypeWillChange] public function jsonSerialize() { $data = []; //Only serialize non-empty arrays. $actorAccess = array_filter($this->actorAccess, function ($actions) { return !empty($actions); }); if ( !empty($actorAccess) ) { $data['actorAccess'] = $actorAccess; } if ( $this->replacementContent !== '' ) { $data['replacementContent'] = $this->replacementContent; } if ( $this->accessProtectionSettings ) { $serializedProtection = $this->accessProtectionSettings->jsonSerialize(); if ( !empty($serializedProtection) ) { $data['accessProtection'] = $serializedProtection; } } if ( $this->preferAdvancedMode ) { $data['preferAdvancedMode'] = true; } return $data; } public static function fromArray($properties) { $actorAccess = []; if ( isset($properties['actorAccess']) && is_array($properties['actorAccess']) ) { $actorAccess = $properties['actorAccess']; } $replacementContent = isset($properties['replacementContent']) ? strval($properties['replacementContent']) : ''; $accessProtectionSettings = null; if ( isset($properties['accessProtection']) ) { $accessProtectionSettings = AccessProtectionSettings::fromArray($properties['accessProtection']); } $preferAdvancedMode = isset($properties['preferAdvancedMode']) && $properties['preferAdvancedMode']; return new static($actorAccess, $replacementContent, $accessProtectionSettings, $preferAdvancedMode); } } class PolicyStore { const POST_POLICY_META_KEY = '_ame_cpe_post_policy'; const TERM_POLICY_META_KEY = '_ame_cpe_term_policy'; const HIDDEN_ITEM_LOOKUP_OPTION = 'ame_cpe_restricted_items'; /** * @var RestrictedPostLookup|null */ private $restrictedPostLookup = null; /** * @var ActionRegistry */ private $actionRegistry; /** * @var array Composite key => array of post IDs. */ private $hiddenListItemCache = []; public function __construct(ActionRegistry $actionRegistry) { $this->actionRegistry = $actionRegistry; //Remove deleted posts from the lookup. add_action('deleted_post', [$this, 'forgetDeletedPost']); } /** * @param string $objectType * @param \WP_Post|\WP_Term $nativeObject * @param string|null $postType For terms, the post type to get the policy for. * @return ContentItemPolicy|null */ public function getObjectPolicy(string $objectType, $nativeObject, ?string $postType = null): ?ContentItemPolicy { switch ($objectType) { case Action::OBJECT_TYPE_POST: if ( !($nativeObject instanceof \WP_Post) ) { return null; } return $this->getPostPolicy($nativeObject->ID); case Action::OBJECT_TYPE_TERM: if ( !($nativeObject instanceof \WP_Term) ) { return null; } return $this->getTermPolicy($nativeObject->term_id, $postType); default: return null; } } public function setObjectPolicy( string $objectType, $nativeObject, ContentItemPolicy $policy, ?string $postType = null ): void { switch ($objectType) { case Action::OBJECT_TYPE_POST: if ( !($nativeObject instanceof \WP_Post) ) { return; } $this->setPostPolicy($nativeObject->ID, $policy); break; case Action::OBJECT_TYPE_TERM: if ( !($nativeObject instanceof \WP_Term) ) { return; } $this->setTermPolicy($nativeObject->term_id, $policy, $postType); break; } } /** * @param int $postId * @return ContentItemPolicy|null */ public function getPostPolicy($postId) { $serializedPolicy = get_post_meta($postId, self::POST_POLICY_META_KEY, true); if ( !empty($serializedPolicy) ) { $properties = json_decode($serializedPolicy, true); if ( is_array($properties) ) { return ContentItemPolicy::fromArray($properties); } } return null; } /** * @param int $postId * @param ContentItemPolicy $policy * @return void */ public function setPostPolicy($postId, ContentItemPolicy $policy) { update_post_meta($postId, self::POST_POLICY_META_KEY, wp_slash(wp_json_encode($policy))); //Clear policy related caches. $this->hiddenListItemCache = []; //Update the central lookup that stores hidden posts. $lookup = $this->getRestrictedPostLookup(); $lookup->forgetPost($postId); $postType = get_post_type($postId); if ( $postType ) { $policy->forEachActionEntry( [ $this->actionRegistry->getAction(ActionRegistry::ACTION_READ), $this->actionRegistry->getAction(ActionRegistry::ACTION_VIEW_IN_LISTS), ], function ($actorId, $actionName, $isAllowed) use ($lookup, $postId, $postType) { $action = $this->actionRegistry->getAction($actionName); $lookup->addEntry($actorId, $action, $postType, $postId, $isAllowed); } ); } if ( $lookup->isModified() ) { $this->saveRestrictedPostLookup($lookup); } } public function getTermPolicy($termId, ?string $postType = null, bool $useUniversalOnMiss = false): ?ContentItemPolicy { $collection = $this->getTermPolicyCollection($termId); if ( $collection === null ) { return null; } if ( $postType === null ) { return $collection->getUniversalPolicy(); } else { $policyForPostType = $collection->getPolicyForPostType($postType); if ( $policyForPostType ) { return $policyForPostType; } else if ( $useUniversalOnMiss ) { return $collection->getUniversalPolicy(); } } return null; } private function setTermPolicy($termId, ContentItemPolicy $policy, ?string $postType = null) { //Unlike a post, a term can have multiple policies - one for each associated post type, //and a general one that applies to all post types. So we can't just overwrite the meta //value, we need to merge the new policy into the existing one(s). $collection = $this->getTermPolicyCollection($termId); if ( $collection === null ) { $collection = new TermPolicyCollection(); } if ( $postType === null ) { $collection->setUniversalPolicy($policy); } else { $collection->setPolicyForPostType($postType, $policy); } $this->setTermPolicyCollection($termId, $collection); } private function getTermPolicyCollection($termId): ?TermPolicyCollection { $collectionData = get_term_meta($termId, self::TERM_POLICY_META_KEY, true); if ( !empty($collectionData) ) { $properties = json_decode($collectionData, true); if ( is_array($properties) ) { return TermPolicyCollection::deserializeFromArray($properties); } } return null; } private function setTermPolicyCollection($termId, TermPolicyCollection $collection) { update_term_meta($termId, self::TERM_POLICY_META_KEY, wp_slash(wp_json_encode($collection))); //todo: Probably need to update caches and lookups here. } /** * @param Actor $actor * @param string[]|null $postTypes * @return int[] */ public function getPostsHiddenFromLists($actor, $postTypes = null) { if ( is_array($postTypes) && empty($postTypes) ) { return []; } $cacheKey = $this->getHiddenListItemCacheKey(Action::OBJECT_TYPE_POST, $actor, $postTypes); if ( isset($this->hiddenListItemCache[$cacheKey]) ) { return $this->hiddenListItemCache[$cacheKey]; } $lookup = $this->getRestrictedPostLookup(); $restrictedPosts = $lookup->getRestrictedPosts( $actor, $this->actionRegistry->getAction(ActionRegistry::ACTION_VIEW_IN_LISTS), $postTypes ); $this->hiddenListItemCache[$cacheKey] = $restrictedPosts; return $restrictedPosts; } /** * Check if there might be *any* posts that the current user isn't allowed to read or view in lists. * * @return bool */ public function mightHaveHiddenPosts(Actor $actor) { $lookup = $this->getRestrictedPostLookup(); $actions = [ $this->actionRegistry->getAction(ActionRegistry::ACTION_READ), $this->actionRegistry->getAction(ActionRegistry::ACTION_VIEW_IN_LISTS), ]; return $lookup->hasPotentialRestrictions($actor, $actions); } private function getRestrictedPostLookup() { if ( $this->restrictedPostLookup === null ) { $storedData = get_option(self::HIDDEN_ITEM_LOOKUP_OPTION, '{}'); $parsed = json_decode((string)$storedData, true); if ( !empty($parsed) && is_array($parsed) ) { $this->restrictedPostLookup = new RestrictedPostLookup($parsed); } else { $this->restrictedPostLookup = new RestrictedPostLookup(); } } return $this->restrictedPostLookup; } private function saveRestrictedPostLookup(RestrictedPostLookup $lookup) { update_option(self::HIDDEN_ITEM_LOOKUP_OPTION, wp_json_encode($lookup)); } /** * Remove a post from the restricted post lookup when it's deleted. * * This is a hook callback and should not be called directly. * * @param int $postId * @return void * @internal */ public function forgetDeletedPost($postId) { if ( !is_numeric($postId) || ($postId <= 0) ) { return; } $lookup = $this->getRestrictedPostLookup(); $lookup->forgetPost($postId); if ( $lookup->isModified() ) { $this->saveRestrictedPostLookup($lookup); } } /** * @param string $objectType * @param Actor $actor * @param array|null $objectWpTypes * @return string */ private function getHiddenListItemCacheKey($objectType, Actor $actor, $objectWpTypes) { $key = $objectType . '|' . $actor->getId() . '|'; if ( $objectWpTypes ) { $key .= implode(',', $objectWpTypes); } return $key; } } class Action implements \JsonSerializable { protected $name; /** * @var string|null */ protected $label = null; /** * @var null|callable */ protected $labelGenerator = null; /** * @var string|null */ protected $description = null; /** * @var null|callable */ protected $descriptionGenerator = null; const OBJECT_TYPE_POST = 'post'; const OBJECT_TYPE_TERM = 'term'; protected $supportedObjectTypes = []; /** * @var string|null */ private $postMetaCap; /** * @var string|null */ private $termMetaCap; /** * @var string|null */ private $group; /** * @param string $name * @param array $supportedObjectTypes * @param string|callable $label * @param string|null $group * @param string|callable $description * @param string|null $postMetaCap * @param string|null $termMetaCap */ public function __construct( string $name, array $supportedObjectTypes, $label, ?string $group = null, $description = '', ?string $postMetaCap = null, ?string $termMetaCap = null ) { $this->name = $name; $this->supportedObjectTypes = $supportedObjectTypes; $this->postMetaCap = $postMetaCap; $this->termMetaCap = $termMetaCap; if ( is_string($label) ) { $this->label = $label; } else if ( $label !== null ) { $this->labelGenerator = $label; } if ( is_string($description) ) { $this->description = $description; } else if ( $description !== null ) { $this->descriptionGenerator = $description; } $this->group = $group; } /** * @return string */ public function getName() { return $this->name; } public function getLabel(?\WP_Post_Type $postType = null, ?\WP_Taxonomy $taxonomy = null): string { if ( ($this->label === null) && is_callable($this->labelGenerator) ) { return call_user_func($this->labelGenerator, $postType, $taxonomy); } return $this->label ?? ''; } /** * @return string */ public function getDescription() { if ( ($this->description === null) && is_callable($this->descriptionGenerator) ) { $this->description = call_user_func($this->descriptionGenerator); } return $this->description; } /** * Checks if the given object type is supported by this action. * * @param string $objectType * @param mixed $nativeObject The specific instance of the object type. This can be a post object, term object, etc. * @return bool * @noinspection PhpUnusedParameterInspection ($objectInstance can be used in subclasses) */ public function appliesToObject($objectType, $nativeObject) { return in_array($objectType, $this->supportedObjectTypes, true); } /** * @return string|null */ public function getPostMetaCap() { return $this->postMetaCap; } public function getTermMetaCap(): ?string { return $this->termMetaCap; } /** @noinspection PhpLanguageLevelInspection */ #[\ReturnTypeWillChange] public function jsonSerialize() { return [ 'name' => $this->name, 'description' => $this->getDescription(), 'group' => $this->group, ]; } } class ActionRegistry { const ACTION_EDIT = 'edit'; const ACTION_DELETE = 'delete'; const ACTION_READ = 'read'; const ACTION_VIEW_IN_LISTS = 'view_in_lists'; const ACTION_PUBLISH = 'publish'; const ACTION_READ_ASSOCIATED_OBJECTS = 'read_associated_objects'; const ACTION_EDIT_ASSOCIATED_OBJECTS = 'edit_associated_objects'; const ACTION_DELETE_ASSOCIATED_OBJECTS = 'delete_associated_objects'; const ACTION_VIEW_ASSOCIATED_OBJECTS_IN_LISTS = 'view_associated_objects_in_lists'; const ACTION_ASSIGN_TERM_TO_OBJECTS = 'assign_term_to_objects'; const GROUP_ASSOCIATED_POSTS = 'associated_posts'; const GROUP_THIS_TERM = 'this_term'; /** * @var Action[] */ protected $actions = []; public function __construct() { $this->addAction(new Action( self::ACTION_READ, [Action::OBJECT_TYPE_POST], function () { return _x('Read', 'content permissions: action name', 'admin-menu-editor'); }, null, '', 'read_post' )); $this->addAction(new Action( self::ACTION_VIEW_IN_LISTS, [Action::OBJECT_TYPE_POST], function () { return _x('View in lists', 'content permissions: action name', 'admin-menu-editor'); }, null, function () { return __( 'Applies to places like the post list in the admin dashboard and archive pages on the front end.', 'admin-menu-editor' ); }, 'read_post' )); $this->addAction(new Action( self::ACTION_EDIT, [Action::OBJECT_TYPE_POST], function () { return _x('Edit', 'content permissions: action name', 'admin-menu-editor'); }, null, '', 'edit_post' )); $this->addAction(new Action( self::ACTION_DELETE, [Action::OBJECT_TYPE_POST], function () { return _x('Delete', 'content permissions: action name', 'admin-menu-editor'); }, null, '', 'delete_post' )); $this->addAction(new Action( self::ACTION_PUBLISH, [Action::OBJECT_TYPE_POST], function () { return _x('Publish', 'content permissions: action name', 'admin-menu-editor'); }, null, '', 'publish_post' )); //Term actions for associated objects $this->addAction(new Action( self::ACTION_READ_ASSOCIATED_OBJECTS, [Action::OBJECT_TYPE_TERM], self::labelHelper(function () { /* translators: %1$s: post type label, %2$s: taxonomy label */ return _x('Read %1$s', 'content permissions: action name', 'admin-menu-editor'); }), self::GROUP_ASSOCIATED_POSTS, '', 'read_post' )); //todo: Should disabling this also prevent the term itself from appearing in the term list? // We probably don't want too many granular options here. $this->addAction(new Action( self::ACTION_VIEW_ASSOCIATED_OBJECTS_IN_LISTS, [Action::OBJECT_TYPE_TERM], self::labelHelper(function () { /* translators: %1$s: post type label */ return _x('View %1$s in lists', 'content permissions: action name', 'admin-menu-editor'); }), self::GROUP_ASSOCIATED_POSTS, '', 'read_post' )); $this->addAction(new Action( self::ACTION_EDIT_ASSOCIATED_OBJECTS, [Action::OBJECT_TYPE_TERM], self::labelHelper(function () { /* translators: %1$s: post type label, %2$s: taxonomy label */ return _x('Edit %1$s', 'content permissions: action name', 'admin-menu-editor'); }), self::GROUP_ASSOCIATED_POSTS, '', 'edit_post' )); $this->addAction(new Action( self::ACTION_DELETE_ASSOCIATED_OBJECTS, [Action::OBJECT_TYPE_TERM], self::labelHelper(function () { /* translators: %1$s: post type label, %2$s: taxonomy label */ return _x('Delete %1$s', 'content permissions: action name', 'admin-menu-editor'); }), self::GROUP_ASSOCIATED_POSTS, '', 'delete_post' )); $this->addAction(new Action( self::ACTION_ASSIGN_TERM_TO_OBJECTS, [Action::OBJECT_TYPE_TERM], self::labelHelper(function () { /* translators: %1$s: post type label, %2$s: taxonomy label */ return _x('Assign this %2$s to %1$s', 'content permissions: action name', 'admin-menu-editor'); }), self::GROUP_THIS_TERM, '', null, 'assign_term' )); } public function addAction(Action $action) { $this->actions[$action->getName()] = $action; } /** * @param string $name * @return Action|null */ public function getAction($name) { return isset($this->actions[$name]) ? $this->actions[$name] : null; } /** * @param string $objectType * @param mixed $nativeObject * @return Action[] */ public function getApplicableActions($objectType, $nativeObject = null) { $actions = []; foreach ($this->actions as $action) { if ( $action->appliesToObject($objectType, $nativeObject) ) { $actions[] = $action; } } return $actions; } private static function labelHelper(callable $templateFn): \Closure { return function (?\WP_Post_Type $postType = null, ?\WP_Taxonomy $taxonomy = null) use ($templateFn) { return sprintf( call_user_func($templateFn), $postType ? $postType->labels->name : __('objects', 'admin-menu-editor'), $taxonomy ? $taxonomy->labels->singular_name : __('term', 'admin-menu-editor') ); }; } } class EvaluationResult { protected $effect; protected $actor; protected $action; protected $objectId; /** * @var ContentItemPolicy $usedAccessPolicy The policy that produced this result. * This can be different from $objectPolicy if the policy was inherited from a parent object. */ protected $usedAccessPolicy; /** * @var ContentItemPolicy|null $objectPolicy The policy directly associated with the object. * This can be NULL if the object doesn't have a policy of its own. */ protected $objectPolicy; public function __construct( $effect, Actor $actor, Action $action, ContentItemPolicy $policy, ?ContentItemPolicy $objectPolicy = null, $objectId = null ) { $this->effect = $effect; $this->actor = $actor; $this->action = $action; $this->usedAccessPolicy = $policy; $this->objectPolicy = $objectPolicy; $this->objectId = $objectId; } public function getEffect() { return $this->effect; } /** * @return Actor */ public function getActor() { return $this->actor; } /** * @return Action */ public function getAction() { return $this->action; } /** * @return ContentItemPolicy */ public function getPolicy() { if ( $this->objectPolicy ) { return $this->objectPolicy; } return $this->usedAccessPolicy; } /** * @return ContentItemPolicy */ public function getUsedAccessPolicy() { return $this->usedAccessPolicy; } /** * @return ContentItemPolicy */ public function getObjectPolicy() { return $this->objectPolicy; } /** * @return mixed|null */ public function getObjectId() { return $this->objectId; } public function isDenied() { return $this->effect === ContentItemPolicy::EFFECT_DENY; } public function isAllowed() { return $this->effect === ContentItemPolicy::EFFECT_ALLOW; } } abstract class AccessProtection implements \JsonSerializable { /** * @param \WP_Post $post Current post. * @param \WP_Post[] $posts List of posts for the current query. * @param \WP_Query|null $query The current query, if applicable. * @return \WP_Post[]|null Filtered list of posts, or NULL to leave the list unchanged. */ abstract public function enforce($post, $posts = [], $query = null); abstract protected function toArray(); /** * @param array $properties * @param ContentItemPolicy|null $ownerPolicy The policy that owns the protection that's being deserialized. * @return AccessProtection */ public static function fromArray($properties, ?ContentItemPolicy $ownerPolicy = null) { if ( !isset($properties['tag']) ) { throw new \InvalidArgumentException('Invalid properties array for an access protection object'); } $tag = $properties['tag']; switch ($tag) { case RedirectProtection::$tag: return RedirectProtection::deserializeFromArray($properties); case ErrorMessageProtection::$tag: //If the error message isn't set, it defaults to the replacement content //set for the policy that owns this protection. $messageCallback = null; if ( $ownerPolicy ) { $messageCallback = [$ownerPolicy, 'getReplacementContent']; } return ErrorMessageProtection::deserializeFromArray($properties, $messageCallback); case NotFoundProtection::$tag: return new NotFoundProtection(); case ContentReplacementProtection::$tag: return new ContentReplacementProtection(); default: throw new \InvalidArgumentException('Unknown access protection tag: ' . esc_html($tag)); } } public function getTag() { return static::$tag; } /** @noinspection PhpLanguageLevelInspection */ #[\ReturnTypeWillChange] public function jsonSerialize() { return $this->toArray(); } } class RedirectProtection extends AccessProtection { protected static $tag = 'redirect'; const REDIRECT_ACTION = 'template_redirect'; const DEFAULT_REDIRECT_CODE = 307; /** * @var string $targetUrl */ private $targetUrl; private $shortcodesEnabled; private $redirectCode; public function __construct($targetUrl, $redirectCode = self::DEFAULT_REDIRECT_CODE, $shortcodesEnabled = false) { $this->targetUrl = is_string($targetUrl) ? $targetUrl : ''; $this->shortcodesEnabled = boolval($shortcodesEnabled); $code = intval($redirectCode); if ( ($code >= 300) && ($code <= 399) ) { $this->redirectCode = $code; } else { $this->redirectCode = self::DEFAULT_REDIRECT_CODE; } } public function enforce($post, $posts = [], $query = null) { if ( did_action(self::REDIRECT_ACTION) ) { $this->performRedirect(); return []; } else { add_action(self::REDIRECT_ACTION, [$this, 'performRedirect']); } return []; } public function performRedirect() { $url = $this->targetUrl; if ( empty($url) ) { $url = $this->getFallbackUrl(); } $parsed = wp_parse_url($url); if ( empty($parsed) ) { $url = $this->getFallbackUrl(); } if ( $this->shortcodesEnabled ) { $url = do_shortcode($url); } //The user provides the redirect URL, and they can choose an external URL if they want. //phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect if ( wp_redirect($url, $this->redirectCode) ) { exit(); } else { wp_die('Error: Redirect failed', 500); } } private function getFallbackUrl() { return home_url(); } protected function toArray() { $result = ['tag' => static::$tag]; if ( $this->redirectCode !== self::DEFAULT_REDIRECT_CODE ) { $result['redirectCode'] = $this->redirectCode; } if ( $this->shortcodesEnabled ) { $result['shortcodesEnabled'] = true; } if ( !empty($this->targetUrl) ) { $result['targetUrl'] = $this->targetUrl; } return $result; } protected static function deserializeFromArray($properties) { return new static( isset($properties['targetUrl']) ? strval($properties['targetUrl']) : '', isset($properties['redirectCode']) ? intval($properties['redirectCode']) : self::DEFAULT_REDIRECT_CODE, !empty($properties['shortcodesEnabled']) ); } } class ErrorMessageProtection extends AccessProtection { protected static $tag = 'errorMessage'; const FALLBACK_MESSAGE = 'Access denied'; /** * @var string */ private $errorMessage; /** * @var callable|null */ private $backupMessageCallback; public function __construct($errorMessage = '', $backupMessageCallback = null) { $this->errorMessage = $errorMessage; $this->backupMessageCallback = $backupMessageCallback; } public function enforce($post, $posts = [], $query = null) { $message = ''; if ( !empty($this->errorMessage) ) { $message = $this->errorMessage; } else if ( !empty($this->backupMessageCallback) ) { $message = call_user_func($this->backupMessageCallback); } if ( empty($message) ) { $message = self::FALLBACK_MESSAGE; } wp_die(wp_kses_post($message), 403); } protected function toArray() { $result = ['tag' => static::$tag]; if ( !empty($this->errorMessage) ) { $result['errorMessage'] = $this->errorMessage; } return $result; } protected static function deserializeFromArray($properties, $messageCallback = null) { return new static( isset($properties['errorMessage']) ? strval($properties['errorMessage']) : '', $messageCallback ); } } class NotFoundProtection extends AccessProtection { protected static $tag = 'notFound'; public function enforce($post, $posts = [], $query = null) { if ( $query instanceof \WP_Query ) { $query->set_404(); } return []; } protected function toArray() { return ['tag' => static::$tag]; } } class ContentReplacementProtection extends AccessProtection { protected static $tag = 'replace'; public function enforce($post, $posts = [], $query = null) { //The actual replacement is handled by the ContentPermissionsEnforcer class which //covers both direct access and indirect access (e.g. via category archives). This //class is a placeholder for the protection type, and helps with serialization. return null; } protected function toArray() { return ['tag' => static::$tag]; } } class AccessProtectionSettings implements \JsonSerializable { /** * @var AccessProtection|null */ private $activeProtection; /** * @var array<string,AccessProtection> */ private $configuredProtectionTypes; public function __construct(?AccessProtection $activeProtection = null, $configuredProtectionTypes = []) { $this->activeProtection = $activeProtection; $this->configuredProtectionTypes = $configuredProtectionTypes; if ( $this->activeProtection ) { $this->configuredProtectionTypes[$activeProtection->getTag()] = $activeProtection; } } /** * @return AccessProtection|null */ public function getActiveProtection() { return $this->activeProtection; } /** * @return array<string,AccessProtection> */ public function getConfiguredProtectionTypes() { return $this->configuredProtectionTypes; } public static function fromArray($data) { $configuredProtectionTypes = []; if ( isset($data['protections']) && is_array($data['protections']) ) { foreach ($data['protections'] as $protectionData) { $protection = AccessProtection::fromArray($protectionData); $configuredProtectionTypes[$protection->getTag()] = $protection; } } $activeProtection = null; if ( isset($data['active']) ) { $activeTag = $data['active']; if ( isset($configuredProtectionTypes[$activeTag]) ) { $activeProtection = $configuredProtectionTypes[$activeTag]; } else { $activeProtection = AccessProtection::fromArray(['tag' => $activeTag]); } } return new static($activeProtection, $configuredProtectionTypes); } /** @noinspection PhpLanguageLevelInspection */ #[\ReturnTypeWillChange] public function jsonSerialize() { $data = []; if ( $this->activeProtection ) { $data['active'] = $this->activeProtection->getTag(); } //We want to preserve protection settings (e.g. redirect URL) even if the corresponding //protection type isn't currently active. So we store all protection types that have any //serialized data (minus the tag). if ( !empty($this->configuredProtectionTypes) ) { $protections = []; foreach ($this->configuredProtectionTypes as $protection) { $serialized = $protection->jsonSerialize(); if ( count($serialized) > 1 ) { $protections[$protection->getTag()] = $serialized; } } if ( !empty($protections) ) { $data['protections'] = $protections; } } return $data; } } class RestrictedPostLookup implements \JsonSerializable { /** * @var array<string,array> */ protected $postAccess; private $wasModified = false; public function __construct($properties = []) { if ( isset($properties['posts']) ) { $this->postAccess = $properties['posts']; } else { $this->postAccess = []; } } /** * @param Actor $actor * @param Action $action * @param array $postTypes * @return int[] */ public function getRestrictedPosts(Actor $actor, Action $action, $postTypes = null) { $actionName = $action->getName(); $allowed = []; $denied = []; foreach ($actor->flatten() as $componentActor) { $actorId = $componentActor->getId(); $priority = $componentActor->getPriority(); $actorSettings = \ameMultiDictionary::get($this->postAccess, [$actorId, $actionName], []); if ( $postTypes === null ) { $relevantPostTypes = $actorSettings; } else { $relevantPostTypes = []; foreach ($postTypes as $postType) { if ( isset($actorSettings[$postType]) ) { $relevantPostTypes[$postType] = $actorSettings[$postType]; } } } foreach ($relevantPostTypes as $postIds) { foreach ($postIds as $postId => $isAllowed) { if ( isset($allowed[$postId]) ) { //Already allowed for another actor of the same or higher priority. continue; } if ( $isAllowed ) { //Allow unless it was denied for an actor with a higher priority. //For example, a "deny" for a user could override an "allow" for a role, //but not vice versa. $hasPriorityDeny = isset($denied[$postId]) && ($denied[$postId] > $priority); if ( !$hasPriorityDeny ) { $allowed[$postId] = $priority; unset($denied[$postId]); } } else { //Due to the earlier "isset($allowed[$postId])" check, we only get here //if there's no higher-priority actor that would override this setting. $denied[$postId] = $priority; } } } } return array_keys($denied); } /** * @param Actor $actor * @param Action[] $actions * @return bool */ public function hasPotentialRestrictions(Actor $actor, $actions) { foreach ($actor->flatten() as $componentActor) { $actorId = $componentActor->getId(); foreach ($actions as $action) { $actionName = $action->getName(); if ( !empty($this->postAccess[$actorId][$actionName]) ) { return true; } } } return false; } /** * @param string $actorId * @param Action $action * @param string $postType * @param int $postId * @param bool $isAllowed * @return void */ public function addEntry($actorId, Action $action, $postType, $postId, $isAllowed) { $actionName = $action->getName(); $isAllowed = boolval($isAllowed); $path = [$actorId, $actionName, $postType, $postId]; $oldValue = \ameMultiDictionary::get($this->postAccess, $path); if ( $oldValue === $isAllowed ) { return; } \ameMultiDictionary::set($this->postAccess, $path, $isAllowed); $this->wasModified = true; } public function removeEntry($actorId, Action $action, $postType, $postId) { $actionName = $action->getName(); if ( isset($this->postAccess[$actorId][$actionName][$postType][$postId]) ) { unset($this->postAccess[$actorId][$actionName][$postType][$postId]); $this->wasModified = true; } } public function forgetPost($postId) { foreach ($this->postAccess as $actorId => $actions) { foreach ($actions as $actionName => $postTypes) { foreach ($postTypes as $postType => $postIds) { if ( isset($postIds[$postId]) ) { unset($this->postAccess[$actorId][$actionName][$postType][$postId]); $this->wasModified = true; } } } } } public function isModified() { return $this->wasModified; } public function clearModifiedFlag() { $this->wasModified = false; } public function toArray() { //Optimization: Keep only non-empty arrays. $filtered = []; foreach ($this->postAccess as $actorId => $actions) { foreach ($actions as $actionName => $postTypes) { foreach ($postTypes as $postType => $postIds) { if ( !empty($postIds) ) { \ameMultiDictionary::set($filtered, [$actorId, $actionName, $postType], $postIds); } } } } return [ 'posts' => $filtered, ]; } public static function deserializeFromArray($properties) { $restrictedPosts = []; if ( isset($properties['posts']) && is_array($properties['posts']) ) { $restrictedPosts = $properties['posts']; } return new static($restrictedPosts); } /** @noinspection PhpLanguageLevelInspection */ #[\ReturnTypeWillChange] public function jsonSerialize() { return $this->toArray(); } } class TermPolicyCollection implements \JsonSerializable { /** * @var array<string,ContentItemPolicy> Keyed by post type */ private array $policies = []; /** * @var ContentItemPolicy|null Universal policy that applies to all post types, if any. */ private ?ContentItemPolicy $universalPolicy = null; /** * @param string $postType * @return ContentItemPolicy|null */ public function getPolicyForPostType(string $postType): ?ContentItemPolicy { if ( isset($this->policies[$postType]) ) { return $this->policies[$postType]; } return null; } /** * @return ContentItemPolicy|null */ public function getUniversalPolicy(): ?ContentItemPolicy { return $this->universalPolicy; } /** * @param string $postType * @param ContentItemPolicy|null $policy * @return void */ public function setPolicyForPostType(string $postType, ?ContentItemPolicy $policy): void { if ( $policy ) { $this->policies[$postType] = $policy; } else { unset($this->policies[$postType]); } } /** * @param ContentItemPolicy|null $policy * @return void */ public function setUniversalPolicy(?ContentItemPolicy $policy): void { $this->universalPolicy = $policy; } /** @noinspection PhpLanguageLevelInspection * @noinspection PhpMissingReturnTypeInspection */ #[\ReturnTypeWillChange] public function jsonSerialize() { $data = []; if ( $this->universalPolicy !== null ) { $data['universal'] = $this->universalPolicy; } if ( !empty($this->policies) ) { $data['byPostType'] = $this->policies; } return $data; } public static function deserializeFromArray(array $data): TermPolicyCollection { $collection = new TermPolicyCollection(); if ( isset($data['universal']) && is_array($data['universal']) ) { $collection->setUniversalPolicy(ContentItemPolicy::fromArray($data['universal'])); } if ( isset($data['byPostType']) && is_array($data['byPostType']) ) { foreach ($data['byPostType'] as $postType => $policyData) { if ( is_array($policyData) ) { $policy = ContentItemPolicy::fromArray($policyData); $collection->setPolicyForPostType($postType, $policy); } } } return $collection; } }