| 1 |
<?php |
| 2 |
declare(strict_types=1); |
| 3 |
|
| 4 |
namespace Imagify\Abilities; |
| 5 |
|
| 6 |
use Imagify\User\User; |
| 7 |
|
| 8 |
/** |
| 9 |
* Base class for all Imagify MCP abilities. |
| 10 |
* |
| 11 |
* Provides the `check_permissions()` template method (fires the |
| 12 |
* `imagify_mcp_permission_denied` action on denial), the `fire_executed()` |
| 13 |
* helper used by concrete `execute()` implementations to fire |
| 14 |
* `imagify_mcp_ability_executed` after every invocation, and the |
| 15 |
* `guard_credit_confirmation()` template method reused by every |
| 16 |
* credit-consuming ability. |
| 17 |
* |
| 18 |
* @since 2.3.0 |
| 19 |
*/ |
| 20 |
abstract class AbstractAbility implements AbilitiesInterface { |
| 21 |
|
| 22 |
/** |
| 23 |
* Returns the ability slug used to identify this ability in hooks and tracking. |
| 24 |
* |
| 25 |
* @return string |
| 26 |
*/ |
| 27 |
abstract public function get_id(): string; |
| 28 |
|
| 29 |
/** |
| 30 |
* Returns the human-readable ability label used in hooks and tracking. |
| 31 |
* |
| 32 |
* @return string |
| 33 |
*/ |
| 34 |
abstract public function get_name(): string; |
| 35 |
|
| 36 |
/** |
| 37 |
* Internal permission check delegated by check_permissions(). |
| 38 |
* |
| 39 |
* @return bool True when the current user may execute the ability. |
| 40 |
*/ |
| 41 |
abstract protected function has_permission(): bool; |
| 42 |
|
| 43 |
/** |
| 44 |
* Returns the capability name reported to `imagify_mcp_permission_denied` |
| 45 |
* when `has_permission()` denies access. |
| 46 |
* |
| 47 |
* Overridable so abilities whose `has_permission()` checks a capability |
| 48 |
* other than the Imagify `manage` capability (e.g. `manage_options`) |
| 49 |
* report the real required capability in tracking/analytics. |
| 50 |
* |
| 51 |
* @return string |
| 52 |
*/ |
| 53 |
protected function get_required_capability(): string { |
| 54 |
return 'manage'; |
| 55 |
} |
| 56 |
|
| 57 |
/** |
| 58 |
* Check if the current user has permission to execute this ability. |
| 59 |
* |
| 60 |
* Delegates the capability check to has_permission() and fires |
| 61 |
* `imagify_mcp_permission_denied` when access is denied so that |
| 62 |
* tracking and logging subscribers can react. |
| 63 |
* |
| 64 |
* @return bool True when the current user may execute the ability. |
| 65 |
*/ |
| 66 |
public function check_permissions(): bool { |
| 67 |
$allowed = $this->has_permission(); |
| 68 |
|
| 69 |
if ( ! $allowed ) { |
| 70 |
do_action( 'imagify_mcp_permission_denied', $this->get_id(), $this->get_name(), $this->get_required_capability() ); |
| 71 |
} |
| 72 |
|
| 73 |
return $allowed; |
| 74 |
} |
| 75 |
|
| 76 |
/** |
| 77 |
* Fire the `imagify_mcp_ability_executed` action after execute() resolves. |
| 78 |
* |
| 79 |
* Called by every concrete execute() so that tracking and other subscribers |
| 80 |
* receive the result for both success and failure outcomes. |
| 81 |
* |
| 82 |
* @param mixed $result Return value of the ability's do_execute(). |
| 83 |
* @param float $start_time microtime(true) captured before do_execute() ran. |
| 84 |
* @param array $args Raw input args forwarded from execute(). |
| 85 |
* @return void |
| 86 |
*/ |
| 87 |
protected function fire_executed( $result, float $start_time, array $args = [] ): void { |
| 88 |
do_action( 'imagify_mcp_ability_executed', $this->get_id(), $this->get_name(), $result, $start_time, $args ); |
| 89 |
} |
| 90 |
|
| 91 |
/** |
| 92 |
* Fetch an initialized Imagify User instance. |
| 93 |
* |
| 94 |
* Extracted into a protected method so that unit tests can override |
| 95 |
* this call without needing to bootstrap the full Imagify API layer. |
| 96 |
* |
| 97 |
* @return User |
| 98 |
*/ |
| 99 |
protected function fetch_user(): User { |
| 100 |
$user = new User(); |
| 101 |
$user->init_user(); |
| 102 |
return $user; |
| 103 |
} |
| 104 |
|
| 105 |
/** |
| 106 |
* Shared pre-flight guard for credit-consuming abilities. |
| 107 |
* |
| 108 |
* Implements a 4-step flow, in this exact order: |
| 109 |
* 1. If the Imagify API key is invalid, returns an `invalid_api_key` |
| 110 |
* response — `$run` is never invoked. |
| 111 |
* 2. If the account is over quota, returns an `insufficient_quota` |
| 112 |
* response — `$run` is never invoked. |
| 113 |
* 3. If `$args['confirm']` is not strictly `true`, returns a |
| 114 |
* `confirmation_required` response built from `get_impact_estimate()` — |
| 115 |
* `$run` is never invoked. |
| 116 |
* 4. Otherwise calls `$run( $args )` and returns its result unchanged. |
| 117 |
* |
| 118 |
* Callers MUST pass a closure created inside the defining ability class |
| 119 |
* (e.g. `function ( array $a ) { return $this->do_execute( $a ); }`), |
| 120 |
* never a `[ $this, 'method' ]` callable-array: a private target method's |
| 121 |
* visibility is resolved against the scope that invokes it, which is this |
| 122 |
* method on `AbstractAbility` — not the ability class where the private |
| 123 |
* method is declared. |
| 124 |
* |
| 125 |
* @param array $args Raw input arguments passed to execute(). |
| 126 |
* @param callable $run Closure invoked with `$args` once confirmed and |
| 127 |
* quota/API-key checks pass. |
| 128 |
* @return array |
| 129 |
*/ |
| 130 |
protected function guard_credit_confirmation( array $args, callable $run ): array { |
| 131 |
if ( ! \Imagify_Requirements::is_api_key_valid() ) { |
| 132 |
return $this->invalid_api_key_response(); |
| 133 |
} |
| 134 |
|
| 135 |
if ( \Imagify_Requirements::is_over_quota() ) { |
| 136 |
return $this->insufficient_quota_response(); |
| 137 |
} |
| 138 |
|
| 139 |
if ( true !== ( $args['confirm'] ?? null ) ) { |
| 140 |
return $this->confirmation_required_response( $args ); |
| 141 |
} |
| 142 |
|
| 143 |
return $run( $args ); |
| 144 |
} |
| 145 |
|
| 146 |
/** |
| 147 |
* Builds the `invalid_api_key` guard response. |
| 148 |
* |
| 149 |
* @return array{status: string, message: string} |
| 150 |
*/ |
| 151 |
private function invalid_api_key_response(): array { |
| 152 |
return [ |
| 153 |
'status' => 'invalid_api_key', |
| 154 |
'message' => __( 'Your Imagify API key is invalid or missing. Update it in the Imagify settings before retrying.', 'imagify' ), |
| 155 |
]; |
| 156 |
} |
| 157 |
|
| 158 |
/** |
| 159 |
* Builds the `insufficient_quota` guard response. |
| 160 |
* |
| 161 |
* @return array{status: string, message: string, next_date_update: string, upgrade_url: string} |
| 162 |
*/ |
| 163 |
private function insufficient_quota_response(): array { |
| 164 |
$user = $this->fetch_user(); |
| 165 |
|
| 166 |
return [ |
| 167 |
'status' => 'insufficient_quota', |
| 168 |
'message' => __( 'Your Imagify quota is exhausted. Wait for the next reset date or upgrade your plan to continue.', 'imagify' ), |
| 169 |
'next_date_update' => $user->next_date_update ? (string) $user->next_date_update : '', |
| 170 |
'upgrade_url' => imagify_get_external_url( |
| 171 |
'subscription', |
| 172 |
[ |
| 173 |
'utm_source' => 'plugin', |
| 174 |
'utm_medium' => 'imagify-wp', |
| 175 |
'utm_content' => 'over-quota', |
| 176 |
] |
| 177 |
), |
| 178 |
]; |
| 179 |
} |
| 180 |
|
| 181 |
/** |
| 182 |
* Builds the `confirmation_required` guard response. |
| 183 |
* |
| 184 |
* The confirmation step is kept for every account, but the messaging adapts |
| 185 |
* to the plan: quota-limited accounts get the credit-consumption wording plus |
| 186 |
* a `quota_remaining` figure, while Infinite accounts (whose plans have no |
| 187 |
* per-image quota to consume) get operation-focused wording and no |
| 188 |
* `quota_remaining` key. |
| 189 |
* |
| 190 |
* @param array $args Raw input arguments passed to execute(). |
| 191 |
* @return array{status: string, message: string, impact: array, quota_remaining?: float, confirm_with: array} |
| 192 |
*/ |
| 193 |
private function confirmation_required_response( array $args ): array { |
| 194 |
$impact = $this instanceof CreditConsumingAbilityInterface ? $this->get_impact_estimate( $args ) : []; |
| 195 |
|
| 196 |
$unit = isset( $impact['unit'] ) ? (string) $impact['unit'] : 'image'; |
| 197 |
$count = isset( $impact['count'] ) ? (int) $impact['count'] : 0; |
| 198 |
$label = isset( $impact['label'] ) ? (string) $impact['label'] : $unit; |
| 199 |
|
| 200 |
$impact_response = [ |
| 201 |
'unit' => $unit, |
| 202 |
'count' => $count, |
| 203 |
]; |
| 204 |
|
| 205 |
if ( isset( $impact['total'] ) ) { |
| 206 |
$impact_response['total'] = (int) $impact['total']; |
| 207 |
} |
| 208 |
|
| 209 |
$user = $this->fetch_user(); |
| 210 |
$is_infinite = $user->is_infinite(); |
| 211 |
$has_total = isset( $impact['total'] ); |
| 212 |
|
| 213 |
if ( $is_infinite ) { |
| 214 |
$message = $has_total |
| 215 |
? sprintf( |
| 216 |
/* translators: 1: number of units about to be processed, 2: total number of units, 3: unit label */ |
| 217 |
__( 'This action will process %1$d of %2$d %3$s. Add "confirm": true to the same call to proceed.', 'imagify' ), |
| 218 |
$count, |
| 219 |
(int) $impact['total'], |
| 220 |
$label |
| 221 |
) |
| 222 |
: sprintf( |
| 223 |
/* translators: 1: number of units about to be processed, 2: unit label */ |
| 224 |
__( 'This action will process %1$d %2$s. Add "confirm": true to the same call to proceed.', 'imagify' ), |
| 225 |
$count, |
| 226 |
$label |
| 227 |
); |
| 228 |
} else { |
| 229 |
$message = $has_total |
| 230 |
? sprintf( |
| 231 |
/* translators: 1: number of units about to be consumed, 2: total number of units, 3: unit label */ |
| 232 |
__( 'This action will consume Imagify quota: %1$d of %2$d %3$s. Add "confirm": true to the same call to proceed.', 'imagify' ), |
| 233 |
$count, |
| 234 |
(int) $impact['total'], |
| 235 |
$label |
| 236 |
) |
| 237 |
: sprintf( |
| 238 |
/* translators: 1: number of units about to be consumed, 2: unit label */ |
| 239 |
__( 'This action will consume Imagify quota: %1$d %2$s. Add "confirm": true to the same call to proceed.', 'imagify' ), |
| 240 |
$count, |
| 241 |
$label |
| 242 |
); |
| 243 |
} |
| 244 |
|
| 245 |
$response = [ |
| 246 |
'status' => 'confirmation_required', |
| 247 |
'message' => $message, |
| 248 |
'impact' => $impact_response, |
| 249 |
'confirm_with' => [ 'confirm' => true ], |
| 250 |
]; |
| 251 |
|
| 252 |
if ( ! $is_infinite ) { |
| 253 |
$response['quota_remaining'] = (float) $user->get_percent_unconsumed_quota(); |
| 254 |
} |
| 255 |
|
| 256 |
return $response; |
| 257 |
} |
| 258 |
} |
| 259 |
|