| @@ -10,13 +10,15 @@ | ||
| 10 | 10 | use WP_REST_Response; |
| 11 | 11 | use Templately\Utils\Base; |
| 12 | 12 | use Templately\Utils\Http; |
| 13 | 13 | use Templately\Utils\Plan; |
| 14 | -use Templately\Core\Module; | |
| 14 | +use Templately\Core\Platform_Registry; | |
| 15 | 15 | use function call_user_func; |
| 16 | 16 | |
| 17 | 17 | use Templately\Utils\Helper; |
| 18 | 18 | use Templately\Utils\Options; |
| 19 | +use Templately\Modules\Auth\REST\Login; | |
| 20 | +use Templately\Utils\Response\Envelope; | |
| 19 | 21 | use function register_rest_route; |
| 20 | 22 | |
| 21 | 23 | /** |
| 22 | 24 | * @method Http http() |
| @@ -41,9 +43,9 @@ | ||
| 41 | 43 | public function __construct() { |
| 42 | 44 | /** |
| 43 | 45 | * Registering as a submodule of the API module |
| 44 | 46 | */ |
| 45 | - Module::get_instance()->add( (object) [ | |
| 47 | + Platform_Registry::get_instance()->add( (object) [ | |
| 46 | 48 | 'object' => $this |
| 47 | 49 | ], 'API' ); |
| 48 | 50 | } |
| 49 | 51 | |
| @@ -107,21 +109,55 @@ | ||
| 107 | 109 | * @return false|mixed |
| 108 | 110 | */ |
| 109 | 111 | public function get_param( $param, $default = '', $sanitizer = 'sanitize_text_field' ) { |
| 110 | 112 | $_value = $this->request->get_param( $param ); |
| 111 | - if ( ! empty( $_value ) ) { | |
| 112 | - if( is_callable($sanitizer) && ! is_array( $_value ) ) { | |
| 113 | - return call_user_func_array( $sanitizer, [ $_value ] ); | |
| 114 | - } elseif ( is_array( $_value ) && is_callable($sanitizer) ) { | |
| 115 | - return array_map( $sanitizer, $_value ); | |
| 116 | - } | |
| 117 | - return $_value; | |
| 113 | + | |
| 114 | + // An ABSENT param — or an explicitly empty string — falls back to the default. | |
| 115 | + // | |
| 116 | + // This used to guard with `! empty()`, which is ALSO false for `0`, `'0'`, | |
| 117 | + // `false` and `[]`. Those are legitimate values a caller may have sent | |
| 118 | + // deliberately: a page number of 0, an explicit `false` flag, an empty array | |
| 119 | + // meaning "clear the selection". They were silently replaced by the default, | |
| 120 | + // and the endpoint could not tell "not sent" from "sent as zero". | |
| 121 | + // | |
| 122 | + // Empty string stays a fallback on purpose — callers like | |
| 123 | + // `get_param( 'search', '' )` rely on it, and "" carries no information a | |
| 124 | + // missing param does not. | |
| 125 | + if ( null === $_value || '' === $_value ) { | |
| 126 | + return $default; | |
| 118 | 127 | } |
| 119 | 128 | |
| 120 | - return $default; | |
| 129 | + return self::sanitize_recursive( $_value, $sanitizer ); | |
| 121 | 130 | } |
| 122 | 131 | |
| 123 | 132 | /** |
| 133 | + * Apply a sanitizer through nested arrays. | |
| 134 | + * | |
| 135 | + * `array_map( $sanitizer, $value )` only reached the FIRST level, so a nested | |
| 136 | + * array left its inner values untouched. | |
| 137 | + * | |
| 138 | + * @param mixed $value | |
| 139 | + * @param callable|string $sanitizer | |
| 140 | + * @return mixed | |
| 141 | + */ | |
| 142 | + private static function sanitize_recursive( $value, $sanitizer ) { | |
| 143 | + if ( ! is_callable( $sanitizer ) ) { | |
| 144 | + return $value; | |
| 145 | + } | |
| 146 | + | |
| 147 | + if ( is_array( $value ) ) { | |
| 148 | + return array_map( | |
| 149 | + function ( $item ) use ( $sanitizer ) { | |
| 150 | + return self::sanitize_recursive( $item, $sanitizer ); | |
| 151 | + }, | |
| 152 | + $value | |
| 153 | + ); | |
| 154 | + } | |
| 155 | + | |
| 156 | + return call_user_func( $sanitizer, $value ); | |
| 157 | + } | |
| 158 | + | |
| 159 | + /** | |
| 124 | 160 | * @param $request WP_REST_Request for getting all route request in time. |
| 125 | 161 | * |
| 126 | 162 | * @return WP_Error|boolean |
| 127 | 163 | */ |
| @@ -173,29 +209,19 @@ | ||
| 173 | 209 | if( ! empty( $endpoint ) ) { |
| 174 | 210 | $_additional_data['endpoint'] = $endpoint; |
| 175 | 211 | } |
| 176 | 212 | |
| 177 | - // Delete user logged meta data — pinned to the acting user, otherwise | |
| 178 | - // Options::user_id() resolves a linked user to the global-login | |
| 179 | - // administrator and clears the administrator's session instead. | |
| 180 | - $options = $this->utils('options'); | |
| 181 | - $options->use_current_user( true ); | |
| 213 | + // One definition of "logged out" (043 / PRD PHP-1). This used to remove its | |
| 214 | + // own list of FIVE keys while Login removed EIGHT, so a session expiring | |
| 215 | + // through this path left `global_login`, `total_download_counts` and | |
| 216 | + // `templates_in_clouds` behind — stale data from the previous account, in a | |
| 217 | + // state that was neither logged in nor logged out. | |
| 218 | + Login::force_logout(); | |
| 182 | 219 | |
| 183 | - try { | |
| 184 | - $options | |
| 185 | - ->remove('user') | |
| 186 | - ->remove('favourites') | |
| 187 | - ->remove('reviews') | |
| 188 | - ->remove('cloud_activity') | |
| 189 | - ->remove('api_key'); | |
| 190 | - | |
| 191 | - if( $options->who_am_i() === 'global' ) { | |
| 192 | - $options->remove_global_login(); | |
| 193 | - } | |
| 194 | - } finally { | |
| 195 | - $options->use_current_user( false ); | |
| 196 | - } | |
| 197 | - | |
| 220 | + // The code stays `invalid_api_key`: it is an outward contract several | |
| 221 | + // callers still branch on, and RestEnvelope already maps it to | |
| 222 | + // AUTH_EXPIRED on the wire. Changing it here would buy nothing and break | |
| 223 | + // those callers. | |
| 198 | 224 | return new WP_Error( 'invalid_api_key', $message, $_additional_data ); |
| 199 | 225 | } |
| 200 | 226 | |
| 201 | 227 | public function get( $endpoint, $callback, $args = [] ){ |
| @@ -241,21 +267,22 @@ | ||
| 241 | 267 | return new WP_REST_Response( $data, 200 ); |
| 242 | 268 | } |
| 243 | 269 | |
| 244 | 270 | /** |
| 245 | - * Enhanced success response wrapper that automatically adds success flag and wraps data | |
| 271 | + * The canonical success envelope (spec 043 / FR-001). | |
| 246 | 272 | * |
| 247 | - * @param $data mixed The data to be wrapped in the response | |
| 273 | + * Replaces the former `successWithData()`, which hand-rolled the same | |
| 274 | + * `{ success, data }` shape in one place while the rest of the plugin | |
| 275 | + * invented its own. The shape now comes from `Envelope`, which is what the | |
| 276 | + * schema and both language halves are tested against. | |
| 248 | 277 | * |
| 278 | + * @param mixed $data The payload. | |
| 279 | + * @param array $meta Optional metadata (pagination, timings, …). | |
| 280 | + * | |
| 249 | 281 | * @return WP_REST_Response |
| 250 | 282 | */ |
| 251 | - public function successWithData( $data ) { | |
| 252 | - $enhanced_response = [ | |
| 253 | - 'success' => true, | |
| 254 | - 'data' => $data | |
| 255 | - ]; | |
| 256 | - | |
| 257 | - return $this->success( $enhanced_response ); | |
| 283 | + public function envelope( $data, $meta = [] ) { | |
| 284 | + return $this->success( Envelope::success( $data, $meta ) ); | |
| 258 | 285 | } |
| 259 | 286 | /** |
| 260 | 287 | * @param $error_code string |
| 261 | 288 | * @param $error_message string|array |
| @@ -274,6 +301,59 @@ | ||
| 274 | 301 | * @return int |
| 275 | 302 | */ |
| 276 | 303 | public function get_plan( $plan = 'all' ) { |
| 277 | 304 | return Plan::get( $plan ); |
| 305 | + } | |
| 306 | + | |
| 307 | + /** | |
| 308 | + * 026/FR-003/D5 — the standard AI-generation response envelope. | |
| 309 | + * | |
| 310 | + * Every generation endpoint emits `{ success, terminal, code, message }`. The | |
| 311 | + * client's retry logic keys off `terminal` ONLY: `terminal:true` ⇒ stop, | |
| 312 | + * `terminal:false` ⇒ keep polling. `code` is for diagnostics/messages. | |
| 313 | + * | |
| 314 | + * The envelope is ADDITIVE — the existing payload ($extra: process_id, | |
| 315 | + * templates, is_local_site, status, …) is merged in, so current consumers | |
| 316 | + * keep reading their fields while gaining the uniform terminal/code signal. | |
| 317 | + * | |
| 318 | + * Code taxonomy: | |
| 319 | + * ok terminal, success — content ready / action succeeded | |
| 320 | + * pending retryable, success — accepted, still working | |
| 321 | + * not_ready retryable, success — content not on disk yet; keep polling | |
| 322 | + * invalid_process terminal, failure — unknown/expired process id | |
| 323 | + * unauthorized terminal, failure — api_key/user mismatch | |
| 324 | + * remote_failed terminal, failure — remote AI service terminal failure | |
| 325 | + * expired terminal, failure — process/session past retention | |
| 326 | + * internal_error terminal, failure — unexpected server error | |
| 327 | + * | |
| 328 | + * @param string $code one of the taxonomy slugs | |
| 329 | + * @param string $message human, i18n | |
| 330 | + * @param array $extra existing payload fields to merge (envelope keys win) | |
| 331 | + * @return array | |
| 332 | + */ | |
| 333 | + public static function ai_envelope( $code, $message = '', $extra = [] ) { | |
| 334 | + return array_merge( (array) $extra, [ | |
| 335 | + 'success' => self::ai_code_is_success( $code ), | |
| 336 | + 'terminal' => self::ai_code_is_terminal( $code ), | |
| 337 | + 'code' => $code, | |
| 338 | + 'message' => $message, | |
| 339 | + ] ); | |
| 340 | + } | |
| 341 | + | |
| 342 | + /** | |
| 343 | + * Whether the client should STOP retrying for this code (FR-003). | |
| 344 | + * `pending` / `not_ready` are the polling states. `internal_error` is also | |
| 345 | + * non-terminal (mirroring AI_INTERNAL_ERROR's retryable=true in the | |
| 346 | + * registry — the fold invariant is terminal === !retryable): transient | |
| 347 | + * manifest/session races produce it, and a single occurrence was killing a | |
| 348 | + * paid, still-running generation. The client poll is attempt-capped, so a | |
| 349 | + * genuinely permanent internal error still terminates, just later. | |
| 350 | + */ | |
| 351 | + public static function ai_code_is_terminal( $code ) { | |
| 352 | + return ! in_array( $code, [ 'pending', 'not_ready', 'internal_error' ], true ); | |
| 353 | + } | |
| 354 | + | |
| 355 | + /** Whether this code represents a non-error response (FR-003). */ | |
| 356 | + public static function ai_code_is_success( $code ) { | |
| 357 | + return in_array( $code, [ 'ok', 'pending', 'not_ready' ], true ); | |
| 278 | 358 | } |
| 279 | 359 | } |