| 1 |
<?php |
| 2 |
|
| 3 |
namespace Give\Campaigns\Actions; |
| 4 |
|
| 5 |
use Give\Campaigns\ValueObjects\CampaignPageMetaKeys; |
| 6 |
use Give\Framework\Database\DB; |
| 7 |
use WP_User; |
| 8 |
|
| 9 |
/** |
| 10 |
* Allow users with Give roles to edit and publish campaign landing pages. |
| 11 |
* |
| 12 |
* Campaign pages are standard WordPress pages (post_type = 'page') that have |
| 13 |
* the give_campaign_id meta key. Give roles like give_worker and give_manager |
| 14 |
* have edit_pages but not edit_others_pages or publish_pages, so this action |
| 15 |
* maps the meta capabilities to allow full management of campaign pages |
| 16 |
* for users with edit_give_forms capability. |
| 17 |
* |
| 18 |
* @since 4.14.0 |
| 19 |
*/ |
| 20 |
class AllowGiveRolesToEditCampaignPages |
| 21 |
{ |
| 22 |
/** |
| 23 |
* Cache for campaign page checks to avoid repeated DB queries. |
| 24 |
* |
| 25 |
* @var array<int, bool> |
| 26 |
*/ |
| 27 |
private static array $campaignPageCache = []; |
| 28 |
|
| 29 |
/** |
| 30 |
* Filter meta capabilities for campaign pages. |
| 31 |
* |
| 32 |
* Hooked to 'map_meta_cap' filter. |
| 33 |
* |
| 34 |
* @since 4.15.3 Handle null $cap gracefully for better compatibility. |
| 35 |
* @since 4.14.0 |
| 36 |
*/ |
| 37 |
public function mapMetaCap(array $caps, ?string $cap, int $userId, array $args): array |
| 38 |
{ |
| 39 |
// Fast check: only handle specific meta capabilities |
| 40 |
static $pageMetaCaps = ['edit_post' => true, 'delete_post' => true, 'publish_post' => true, 'read_post' => true]; |
| 41 |
if (!is_string($cap) || !isset($pageMetaCaps[$cap])) { |
| 42 |
return $caps; |
| 43 |
} |
| 44 |
|
| 45 |
// We need a post ID to check |
| 46 |
if (empty($args[0])) { |
| 47 |
return $caps; |
| 48 |
} |
| 49 |
|
| 50 |
// Check if user has Give capability first (cheaper than DB lookups) |
| 51 |
if (!user_can($userId, 'edit_give_forms')) { |
| 52 |
return $caps; |
| 53 |
} |
| 54 |
|
| 55 |
// Check if this is a campaign page (uses cache) |
| 56 |
if (!$this->isCampaignPage((int)$args[0])) { |
| 57 |
return $caps; |
| 58 |
} |
| 59 |
|
| 60 |
// Grant full access to campaign pages |
| 61 |
return []; |
| 62 |
} |
| 63 |
|
| 64 |
/** |
| 65 |
* Dynamically grant page capabilities when working with a campaign page. |
| 66 |
* |
| 67 |
* The block editor and REST API check primitive capabilities like 'publish_pages' |
| 68 |
* directly (not through map_meta_cap), so we need to dynamically add them. |
| 69 |
* |
| 70 |
* Hooked to 'user_has_cap' filter. |
| 71 |
* |
| 72 |
* @since 4.14.0 |
| 73 |
*/ |
| 74 |
public function grantPublishCapability(array $allcaps, array $caps, array $args, WP_User $user): array |
| 75 |
{ |
| 76 |
// Fast check: skip if not in admin or REST context |
| 77 |
if (!is_admin() && !wp_is_serving_rest_request()) { |
| 78 |
return $allcaps; |
| 79 |
} |
| 80 |
|
| 81 |
// Fast check: only process if specific page caps are requested |
| 82 |
static $pageCaps = ['publish_pages' => true, 'edit_others_pages' => true, 'edit_published_pages' => true, 'delete_others_pages' => true]; |
| 83 |
$requestedPageCaps = array_filter($caps, static function ($cap) use ($pageCaps) { |
| 84 |
return is_string($cap) && isset($pageCaps[$cap]); |
| 85 |
}); |
| 86 |
if (empty($requestedPageCaps)) { |
| 87 |
return $allcaps; |
| 88 |
} |
| 89 |
|
| 90 |
// User must have edit_give_forms capability (check from allcaps, no DB query) |
| 91 |
if (empty($allcaps['edit_give_forms'])) { |
| 92 |
return $allcaps; |
| 93 |
} |
| 94 |
|
| 95 |
// Get the post being edited (cached) |
| 96 |
$postId = $this->getCurrentEditingPostId(); |
| 97 |
if (!$postId) { |
| 98 |
return $allcaps; |
| 99 |
} |
| 100 |
|
| 101 |
// Check if this is a campaign page (uses cache) |
| 102 |
if (!$this->isCampaignPage($postId)) { |
| 103 |
return $allcaps; |
| 104 |
} |
| 105 |
|
| 106 |
// Grant the requested page capabilities |
| 107 |
foreach ($requestedPageCaps as $cap) { |
| 108 |
$allcaps[$cap] = true; |
| 109 |
} |
| 110 |
|
| 111 |
return $allcaps; |
| 112 |
} |
| 113 |
|
| 114 |
/** |
| 115 |
* Check if a post is a campaign page (with caching). |
| 116 |
* |
| 117 |
* @since 4.16.7 Read the campaign ID meta directly instead of through get_post_meta(). |
| 118 |
* @since 4.14.0 |
| 119 |
*/ |
| 120 |
private function isCampaignPage(int $postId): bool |
| 121 |
{ |
| 122 |
if (isset(self::$campaignPageCache[$postId])) { |
| 123 |
return self::$campaignPageCache[$postId]; |
| 124 |
} |
| 125 |
|
| 126 |
$post = get_post($postId); |
| 127 |
if (!$post || $post->post_type !== 'page') { |
| 128 |
self::$campaignPageCache[$postId] = false; |
| 129 |
|
| 130 |
return false; |
| 131 |
} |
| 132 |
|
| 133 |
/* |
| 134 |
* get_post_meta() fires the get_post_metadata filter, which third parties hook to run |
| 135 |
* capability checks. Those re-enter this action through map_meta_cap and recurse until |
| 136 |
* the call stack is exhausted, so read the meta without going through the filter. |
| 137 |
*/ |
| 138 |
$campaignPageMeta = DB::table('postmeta') |
| 139 |
->select('meta_value') |
| 140 |
->where('post_id', $postId) |
| 141 |
->where('meta_key', CampaignPageMetaKeys::CAMPAIGN_ID) |
| 142 |
->get(); |
| 143 |
|
| 144 |
self::$campaignPageCache[$postId] = !empty($campaignPageMeta->meta_value); |
| 145 |
|
| 146 |
return self::$campaignPageCache[$postId]; |
| 147 |
} |
| 148 |
|
| 149 |
/** |
| 150 |
* Get the post ID currently being edited (with static caching). |
| 151 |
* |
| 152 |
* @since 4.14.0 |
| 153 |
*/ |
| 154 |
private function getCurrentEditingPostId(): ?int |
| 155 |
{ |
| 156 |
static $cachedPostId = null; |
| 157 |
static $checked = false; |
| 158 |
|
| 159 |
if ($checked) { |
| 160 |
return $cachedPostId; |
| 161 |
} |
| 162 |
$checked = true; |
| 163 |
|
| 164 |
// Check for post ID in query string (standard edit screen) |
| 165 |
if (!empty($_GET['post'])) { |
| 166 |
$cachedPostId = (int)$_GET['post']; |
| 167 |
return $cachedPostId; |
| 168 |
} |
| 169 |
|
| 170 |
// Check REST API for post ID (query param or route) |
| 171 |
if (wp_is_serving_rest_request()) { |
| 172 |
if (!empty($_GET['post_id'])) { |
| 173 |
$cachedPostId = (int)$_GET['post_id']; |
| 174 |
return $cachedPostId; |
| 175 |
} |
| 176 |
|
| 177 |
$cachedPostId = $this->getPostIdFromRestRoute(); |
| 178 |
if ($cachedPostId) { |
| 179 |
return $cachedPostId; |
| 180 |
} |
| 181 |
} |
| 182 |
|
| 183 |
// Check global $post |
| 184 |
global $post; |
| 185 |
if ($post instanceof \WP_Post) { |
| 186 |
$cachedPostId = $post->ID; |
| 187 |
return $cachedPostId; |
| 188 |
} |
| 189 |
|
| 190 |
return null; |
| 191 |
} |
| 192 |
|
| 193 |
/** |
| 194 |
* Extract post ID from REST API route. |
| 195 |
* |
| 196 |
* @since 4.14.0 |
| 197 |
*/ |
| 198 |
private function getPostIdFromRestRoute(): ?int |
| 199 |
{ |
| 200 |
static $cachedRestPostId = null; |
| 201 |
static $restChecked = false; |
| 202 |
|
| 203 |
if ($restChecked) { |
| 204 |
return $cachedRestPostId; |
| 205 |
} |
| 206 |
$restChecked = true; |
| 207 |
|
| 208 |
global $wp; |
| 209 |
$restRoute = $wp->query_vars['rest_route'] ?? ''; |
| 210 |
|
| 211 |
// Match routes like /wp/v2/pages/123 |
| 212 |
if (preg_match('#/wp/v2/pages/(\d+)#', $restRoute, $matches)) { |
| 213 |
$cachedRestPostId = (int)$matches[1]; |
| 214 |
} |
| 215 |
|
| 216 |
return $cachedRestPostId; |
| 217 |
} |
| 218 |
} |
| 219 |
|
| 220 |
|