PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / trunk
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! vtrunk
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / modules / auth / REST / Login.php

Login.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! trunk, at modules/auth/REST/Login.php

468 lines 17.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Templately\Modules\Auth\REST;
4
5 use WP_REST_Request;
6 use Templately\API\API;
7 use Templately\Utils\Helper;
8 use Templately\Utils\Options;
9 use Templately\Utils\Response\ErrorCode;
10 use Templately\Utils\Response\ResponseNormalizer;
11
12 class Login extends API {
13 /**
14 * The plan-catalog cache key. Versioned with the FIELD SET of the query in
15 * `pricing()`, not with a release: a wider query under the old key would keep
16 * serving the narrower cached answer for up to a week.
17 */
18 const PRICING_TRANSIENT = 'templately_subscriptions_v3';
19
20 public function permission_check( WP_REST_Request $request ) {
21 $this->request = $request;
22 $_route = $request->get_route();
23 if ( '/templately/v1/login' === $_route ) {
24 return true;
25 }
26
27 if ( '/templately/v1/pricing' === $_route ) {
28 return true;
29 }
30
31 if ( '/templately/v1/google-auth-url' === $_route ) {
32 return true;
33 }
34
35 return parent::permission_check( $request );
36 }
37
38 public function register_routes() {
39 $this->post( 'login', [$this, 'login'] );
40 $this->post( 'logout', [$this, 'logout'] );
41 $this->get( 'is-signed', [$this, 'is_signed'] );
42 $this->get( 'pricing', [$this, 'pricing'] );
43 $this->get( 'google-auth-url', [$this, 'google_auth_url'] );
44 }
45
46 public function google_auth_url() {
47 // Get redirect_to parameter from request if provided
48 $redirect_to = $this->get_param( 'redirect-to', '' );
49
50 // Use client-provided current_url instead of HTTP_REFERER for reliability
51 $current_url = $this->get_param( 'current_url', '' );
52
53 $url = $this->http()->google_auth_url( $redirect_to, $current_url );
54 return [
55 'status' => 'success',
56 'url' => $url
57 ];
58 }
59
60 public function pricing(){
61 // Transient key is versioned ON PURPOSE. The field set below widened, and
62 // the cache lives for a WEEK — without a new key every site that had
63 // visited the Subscription screen recently would keep serving the old,
64 // narrow payload and the plan grid would stay full of dashes. Bump the
65 // suffix whenever the query changes. (_v3: `is_bundle` + `plugins`, so
66 // the Subscription screen can tell a bundle plan from a regular one.)
67 $data = get_transient( self::PRICING_TRANSIENT );
68
69 if( is_array( $data ) && ! empty( $data ) ) {
70 return $data;
71 }
72
73 // Field set drives the Subscription screen's plan comparison grid, so it
74 // carries the per-plan limits too, not just price/sites. `is_bundle` and
75 // `plugins{ plugin_original_slug }` identify the bundle plans (a site tier
76 // sold together with Essential Addons Pro and/or Essential Blocks Pro);
77 // without them every bundle renders as one more column under its raw
78 // admin name — "Gutenberg PRO bundle", nine of them on the live catalog.
79 $query = 'id, price, name, slug, discounted_price, type, sites, coupon, my_cloud_items, pro_items, workspace, fsi_limit, ai_credit, description, is_bundle, plugins{ id, name, plugin_original_slug }';
80 $response = $this->http()->query(
81 'subscriptionPlans',
82 $query
83 )->post();
84
85 set_transient( self::PRICING_TRANSIENT, $response, WEEK_IN_SECONDS );
86
87 return $response;
88 }
89
90 public function login() {
91 $errors = [];
92 $_ip = Helper::get_ip();
93 $_site_url = home_url( '/' );
94
95 $global_signin = (bool) $this->get_param( 'global_signin', false );
96 $viaAPI = (bool) $this->get_param( 'viaAPI', false );
97 $email = $this->get_param( 'email', '', 'sanitize_email' );
98 $password = $this->get_param( 'password' );
99
100 $funcArgs = [
101 'ip' => $_ip,
102 'site_url' => $_site_url
103 ];
104
105 $postArgs = [];
106
107 if ( $viaAPI ) {
108 $api_key = $this->get_param( 'api_key' );
109 $funcArgs['api_key'] = $api_key;
110
111 if ( empty( $api_key ) ) {
112 $errors['api_key'] = __( 'API Key field cannot be empty.', 'templately' );
113 }
114 } else {
115 $funcArgs['email'] = $email;
116 $funcArgs['password'] = addcslashes( $password, '"' );
117
118 if ( ! filter_var( $email, FILTER_VALIDATE_EMAIL ) ) {
119 $errors['email'] = __( 'Make sure you have given a valid email address.', 'templately' );
120 }
121
122 if ( empty( $password ) ) {
123 $errors['password'] = __( 'Password field cannot be empty.', 'templately' );
124 }
125 }
126
127 if ( ! empty( $errors ) ) {
128 return $this->error( 'login_error', $errors, 'login', 400 );
129 }
130
131 // `statusText` is the upstream's machine-readable failure identifier — the
132 // same vocabulary ResponseNormalizer maps everywhere else. It is requested
133 // here because a refused connect still answers HTTP 200 with a `user` node
134 // that merely lacks `api_key`, so the normalizer never sees a failure and
135 // the only signal left would be the prose in `message`.
136 // `subscription_plan_id` is what CurrentPlanCard matches against the
137 // /pricing catalog to resolve the billing interval. Without it the card
138 // falls back to guessing from `plan_expire_at` and shows "Lifetime" for
139 // every yearly/monthly subscriber whose expiry is momentarily empty.
140 // `ends_at`, `plan_type` and `cancel_at_period_end` drive the Subscription
141 // screen's renewal line. They are asked for instead of leaning on
142 // `plan_expire_at`, which the API resolves with `empty($subscription->ends_at)
143 // ?? …` — a boolean that never falls through, so it never carries a date.
144 $query = 'status, message, statusText, user{ id, name, first_name, last_name, display_name, email, profile_photo, joined, is_verified, is_company_user, is_restricted_company_user, api_key, plan, plan_expire_at, my_cloud{ limit, usages, last_pushed }, favourites{ id, type }, show_notice, reviews{ type, type_id, rating }, subscription { id, name, sites, subscription_plan_id, ends_at, plan_type, cancel_at_period_end } }';
145
146 $response = $this->http()->mutation(
147 $viaAPI ? 'connectWithApiKey' : 'connect',
148 $query,
149 $funcArgs
150 )->post($postArgs);
151
152 if ( is_wp_error( $response ) ) {
153 return $response;
154 }
155
156 if ( empty( $response['user']['api_key'] ) ) {
157 return $this->login_refused( $response );
158 }
159
160 $options = $this->utils( 'options' );
161 $options->use_current_user( true );
162
163 try {
164 return $this->store_connection( $response, $global_signin, $_ip, $_site_url );
165 } finally {
166 $options->use_current_user( false );
167 }
168 }
169
170 /**
171 * The error for a connect the cloud refused.
172 *
173 * A refusal arrives as HTTP 200 with a `user` node carrying no `api_key`, so
174 * `ResponseNormalizer` never classified it and the only thing left to show
175 * was the upstream's prose. That prose is authored remotely, carries markup
176 * (the site-limit copy links to /subscription), and — once it travelled back
177 * through a redirect query param — was attacker-controlled by the time the
178 * sign-in screen rendered it.
179 *
180 * So the code travels, never the message: `statusText` resolves through the
181 * ONE registry map and the client renders its own copy from `errorCatalog`.
182 * The message is still attached for diagnostics and as the fallback for a
183 * vocabulary word this version does not know — it is rendered as text.
184 *
185 * @param array $response Decoded cloud response.
186 *
187 * @return \WP_Error
188 */
189 private function login_refused( $response ) {
190 $status_text = isset( $response['statusText'] ) && is_string( $response['statusText'] )
191 ? sanitize_text_field( $response['statusText'] )
192 : '';
193
194 $code = ResponseNormalizer::code_for_status_text( $status_text ) ?: ErrorCode::INVALID_API_KEY;
195 $message = ! empty( $response['message'] ) && is_string( $response['message'] )
196 ? $response['message']
197 : __( 'Invalid API key.', 'templately' );
198
199 return $this->error( $code, $message, 'login', ErrorCode::status( $code ), [
200 'context' => [ 'status_text' => $status_text ],
201 ] );
202 }
203
204 /**
205 * Resolve a failed login to the identifier the sign-in screen keys off.
206 *
207 * Used by the Google callback, which can only hand a single value back
208 * through the redirect URL. Anything without a registry code collapses to
209 * `INVALID_API_KEY`, so nothing upstream-authored ever reaches the URL.
210 *
211 * @param mixed $response Result of the login request.
212 *
213 * @return string
214 */
215 public static function resolve_error_code( $response ) {
216 if ( is_wp_error( $response ) ) {
217 $code = $response->get_error_code();
218
219 if ( is_string( $code ) && ErrorCode::exists( $code ) ) {
220 return $code;
221 }
222 }
223
224 return ErrorCode::INVALID_API_KEY;
225 }
226
227 /**
228 * Persist an authenticated connection against the acting user.
229 *
230 * @param array $response Cloud response, already validated.
231 * @param bool $global_signin Whether the user asked to sign in globally.
232 * @param string $_ip Request IP, echoed back into the profile.
233 * @param string $_site_url Site URL, echoed back into the profile.
234 *
235 * @return array
236 */
237 private function store_connection( $response, $global_signin, $_ip, $_site_url ) {
238
239 if ( $global_signin && ! Login::is_globally_signed() ) {
240 Options::set_global_login();
241 }
242
243 if ( ! empty( $response['user']['api_key'] ) ) {
244 $this->utils( 'options' )->set( 'api_key', $response['user']['api_key'] );
245 unset( $response['user']['api_key'] );
246 }
247
248 $meta = [
249 'is_globally_signed' => Login::is_globally_signed(),
250 'signed_as_global' => Login::signed_as_global()
251 ];
252
253 if ( ! empty( $response['user']['my_cloud']['last_pushed'] ) ) {
254 // Cloud response body = untrusted input: never hydrate objects from it.
255 $_cloud_activity = unserialize( $response['user']['my_cloud']['last_pushed'], [ 'allowed_classes' => false ] );
256 $this->utils( 'options' )->set( 'cloud_activity', $_cloud_activity );
257 $meta['cloud_activity'] = $_cloud_activity;
258 unset( $response['user']['my_cloud']['last_pushed'] );
259 }
260
261 if ( ! empty( $response['user']['favourites'] ) ) {
262 $_favourites = $this->utils( 'helper' )->normalizeFavourites( $response['user']['favourites'] );
263 $this->utils( 'options' )->set( 'favourites', $_favourites );
264
265 unset( $response['user']['favourites'] );
266 $meta['favourites'] = $_favourites;
267 }
268
269 if ( ! empty( $response['user']['reviews'] ) ) {
270 $_reviews = $this->utils( 'helper' )->normalizeReviews( $response['user']['reviews'] );
271 $this->utils( 'options' )->set( 'reviews', $_reviews );
272
273 unset( $response['user']['reviews'] );
274 $meta['reviews'] = $_reviews;
275 }
276
277 if(Helper::is_dev_api()){
278 $response['user']['is_dev_api'] = true;
279 }
280
281 if(! empty( $response['user'] ) && is_array($response['user'])){
282 $response['user']['ip'] = $_ip;
283 $response['user']['site_url'] = base64_encode( $_site_url );
284 }
285
286 $this->utils( 'options' )->set( 'user', $response['user'] );
287 $response['user']['meta'] = $this->user_meta( $meta );
288
289 return $response;
290 }
291
292 public function logout() {
293 // Read the key off the acting user's OWN record. `Options::get()` falls back
294 // to the global-login administrator when no target is given, so
295 // `$this->api_key` resolves to the administrator's key for any linked user —
296 // and the outbound `disconnect` would then revoke the administrator's site on
297 // the cloud. Refuse before contacting the cloud when the caller holds no key.
298 //
299 // No pin here: `force_logout()` (via `delete()` below) holds one, and the pin
300 // is a single flag on the singleton — nesting it would release the outer one.
301 $api_key = $this->utils( 'options' )->get( 'api_key', '', get_current_user_id() );
302
303 if ( empty( $api_key ) ) {
304 return $this->error(
305 'logout_error',
306 __( 'You are not connected to Templately.', 'templately' ),
307 'logout',
308 403
309 );
310 }
311
312 $remote_response = $this->http()->mutation(
313 'disconnect',
314 'status, message, data',
315 [
316 'api_key' => $api_key,
317 "site_url" => home_url( '/' )
318 ]
319 )->post();
320
321 // Fail-safe logout (FR-004): a failed remote call must never leave the
322 // user stuck signed in locally. Capture the remote failure (if any) but
323 // always proceed to clear local credentials below.
324 $remote_error = null;
325
326 if ( is_wp_error( $remote_response ) ) {
327 $remote_error = $remote_response->get_error_message();
328 } elseif ( ! isset( $remote_response['status'] ) || $remote_response['status'] !== 'success' ) {
329 $remote_error = $remote_response['message'] ?? __( 'Failed to invalidate the remote session.', 'templately' );
330 }
331
332 // Remove All Metas — unconditional, regardless of remote outcome.
333 $global_user = $this->delete();
334
335 $response = [
336 'status' => 'success',
337 'message' => __( 'Logged out.', 'templately' )
338 ];
339
340 if ( ! empty( $global_user ) ) {
341 $response['global_user'] = $global_user;
342 }
343
344 if ( $remote_error !== null ) {
345 // Surfaced, not swallowed: local state is clean, but the caller should
346 // still learn the remote session may not have been invalidated.
347 $response['remote_error'] = $remote_error;
348 }
349
350 return $response;
351 }
352
353 /**
354 * Tear down the stored session — the ONE place that decides what "logged out"
355 * means (spec 043 / PRD PHP-1).
356 *
357 * `API::permission_error()` used to remove its own list of FIVE keys while this
358 * class removed EIGHT, so an expired session cleared through that path left
359 * `global_login`, `total_download_counts` and `templates_in_clouds` behind —
360 * a partially logged-out state carrying stale data from the previous account.
361 * Both paths now call this.
362 *
363 * The removals are PINNED to the acting user. Without the pin,
364 * `Options::user_id()` resolves a `link` user who holds no connection of their
365 * own to the global-login administrator, so a Contributor POSTing `logout`
366 * cleared the ADMINISTRATOR's `_templately_api_key` and `_templately_user`.
367 * `permission_error()` reaches here too — from `Http` on an `Unauthorized`
368 * reply, where no permission gate stands in front of it.
369 *
370 * Trade-off: a linked user hitting `Unauthorized` no longer clears the
371 * administrator's stale key; only the administrator's own request does.
372 *
373 * @return void
374 */
375 public static function force_logout() {
376 $options = \Templately\Utils\Options::get_instance();
377
378 $options->use_current_user( true );
379
380 try {
381 $options
382 ->remove( 'user' )
383 ->remove( 'favourites' )
384 ->remove( 'reviews' )
385 ->remove( 'cloud_activity' )
386 ->remove( 'api_key' )
387 ->remove( 'global_login' )
388 ->remove( 'total_download_counts' )
389 ->remove( 'templates_in_clouds' );
390
391 if ( $options->who_am_i() === 'global' ) {
392 $options->remove_global_login();
393 }
394 } finally {
395 $options->use_current_user( false );
396 }
397 }
398
399 public function delete(){
400 self::force_logout();
401
402 $global_user_id = $this->utils( 'options' )->is_global();
403 $global_user = null;
404
405 if ( $global_user_id !== $this->utils( 'options' )->current_user_id() ) {
406 $global_user = $this->utils( 'options' )->get( 'user', false, $global_user_id );
407
408 if ( ! empty( $global_user ) ) {
409 if ( is_array( $global_user ) ) {
410 unset( $global_user['api_key'] );
411 }
412
413 $global_user['meta'] = $this->user_meta();
414 }
415 }
416
417 return $global_user;
418 }
419
420 public static function is_signed(): array {
421 $_response = [
422 'status' => 'success'
423 ];
424
425 $_user = ( new static )->utils( 'options' )->get( 'user', null );
426
427 if ( ! is_null( $_user ) ) {
428 // Profiles stored before 3.7.1 may still carry the cloud API key.
429 if ( is_array( $_user ) ) {
430 unset( $_user['api_key'] );
431 }
432
433 $_user['meta'] = self::get_instance()->user_meta();
434 }
435
436 if ( empty( $_user ) ) {
437 $_response['status'] = 'error';
438 }
439
440 $_response['user'] = $_user;
441
442 return $_response;
443 }
444
445 public function user_meta( $meta = [] ): array {
446 $_meta = [
447 'link_account' => self::utils( 'options' )->link_account(),
448 'unlink_account' => self::utils( 'options' )->unlink_account(),
449 'is_globally_signed' => Login::is_globally_signed(),
450 'signed_as_global' => Login::signed_as_global(),
451 'starred' => self::utils( 'options' )->get( 'favourites' ),
452 'reviews' => self::utils( 'options' )->get( 'reviews' ),
453 'cloud_activity' => self::utils( 'options' )->get( 'cloud_activity' ),
454 'has_api' => rest_sanitize_boolean( self::utils( 'options' )->get( 'api_key' ) )
455 ];
456
457 return array_merge( $_meta, $meta );
458 }
459
460 public static function is_globally_signed(): bool {
461 return rest_sanitize_boolean( ( new static )->utils( 'options' )->is_globally_signed() );
462 }
463
464 public static function signed_as_global(): bool {
465 return rest_sanitize_boolean( ( new static )->utils( 'options' )->signed_as_global() );
466 }
467 }
468