PluginProbe
Timetics – Appointment Booking Calendar & Scheduling / 1.0.64
Timetics – Appointment Booking Calendar & Scheduling v1.0.64
1.0.64 1.0.62 1.0.63 1.0.61 1.0.60 1.0.59 1.0.58 1.0.57 1.0.56 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 1.0.17 1.0.18 1.0.19 1.0.2 1.0.20 1.0.21 All 65 releases
timetics / core / addon / api-addon.php

api-addon.php in Timetics – Appointment Booking Calendar & Scheduling 1.0.64, at core/addon/api-addon.php

427 lines 14.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Addon REST API Controller
4 *
5 * @package Timetics
6 */
7
8 namespace Timetics\Core\Addon;
9
10 defined( 'ABSPATH' ) || exit;
11
12 use Timetics\Base\Api;
13 use Timetics\Utils\Singleton;
14 use Arraytics\ToolsSdk\PluginManager;
15 use WP_REST_Request;
16
17 /**
18 * Class Api_Addon
19 *
20 * Handles GET (list) and PUT (status update) for Arraytics plugins
21 * displayed on the About Us page.
22 *
23 * @since 1.0.0
24 */
25 class Api_Addon extends Api {
26
27 use Singleton;
28
29 /**
30 * REST namespace.
31 *
32 * @var string
33 */
34 protected $namespace = 'timetics/v1';
35
36 /**
37 * REST base route.
38 *
39 * @var string
40 */
41 protected $rest_base = 'addons';
42
43 /**
44 * Register REST routes.
45 *
46 * @return void
47 */
48 public function register_routes() {
49 register_rest_route(
50 $this->namespace,
51 '/' . $this->rest_base,
52 [
53 [
54 'methods' => \WP_REST_Server::READABLE,
55 'callback' => [ $this, 'get_items' ],
56 'permission_callback' => [ $this, 'get_items_permissions_check' ],
57 'args' => [
58 'type' => [
59 'description' => __( 'Filter by extension type: module, addon, plugin, or all.', 'timetics' ),
60 'type' => 'string',
61 'enum' => [ 'module', 'addon', 'plugin', 'all' ],
62 'default' => 'all',
63 ],
64 ],
65 ],
66 [
67 'methods' => \WP_REST_Server::EDITABLE,
68 'callback' => [ $this, 'update_item' ],
69 'permission_callback' => [ $this, 'update_item_permissions_check' ],
70 ],
71 ]
72 );
73 }
74
75 /**
76 * Permission check for GET.
77 *
78 * @return bool
79 */
80 public function get_items_permissions_check( $request ) {
81 return current_user_can( 'manage_options' );
82 }
83
84 /**
85 * Permission check for PUT/POST.
86 *
87 * @return bool
88 */
89 public function update_item_permissions_check( $request ) {
90 return current_user_can( 'manage_options' );
91 }
92
93 /**
94 * GET /timetics/v1/addons
95 *
96 * Returns the addon list filtered by ?type=module|addon|plugin|all.
97 *
98 * @param WP_REST_Request $request
99 * @return \WP_REST_Response
100 */
101 public function get_items( $request ) {
102 $type = ! empty( $request['type'] ) ? sanitize_key( $request['type'] ) : 'all';
103 $extensions = timetics_extension();
104
105 $type_map = [
106 'module' => [ $extensions, 'get_modules' ],
107 'addon' => [ $extensions, 'get_addons' ],
108 'plugin' => [ $extensions, 'get_plugins' ],
109 'all' => [ $extensions, 'get' ],
110 ];
111
112 if ( ! isset( $type_map[ $type ] ) ) {
113 return $this->send_error(
114 __( 'Invalid extension type.', 'timetics' ),
115 [ 'status' => 400 ]
116 );
117 }
118
119 $items = array_values( call_user_func( $type_map[ $type ] ) );
120
121 return rest_ensure_response(
122 [
123 'success' => true,
124 'data' => $items,
125 ]
126 );
127 }
128
129 /**
130 * PUT /timetics/v1/addons
131 *
132 * Updates the status of an Arraytics plugin (install/activate/deactivate/upgrade).
133 *
134 * @param WP_REST_Request $request
135 * @return \WP_REST_Response
136 */
137 public function update_item( $request ) {
138 $params = json_decode( $request->get_body(), true );
139
140 $name = isset( $params['name'] ) ? sanitize_text_field( $params['name'] ) : '';
141 $status = isset( $params['status'] ) ? sanitize_text_field( $params['status'] ) : '';
142
143 $valid_statuses = [ 'install', 'activate', 'deactivate', 'upgrade' ];
144
145 if ( empty( $name ) ) {
146 return $this->send_error(
147 __( 'Please enter an extension name.', 'timetics' ),
148 [ 'status' => 422 ]
149 );
150 }
151
152 if ( empty( $status ) || ! in_array( $status, $valid_statuses, true ) ) {
153 return $this->send_error(
154 /* translators: %s: status value */
155 sprintf( __( 'Invalid status "%s" provided.', 'timetics' ), $status ),
156 [ 'status' => 422 ]
157 );
158 }
159
160 // The Ask AI setup dialog lets the user edit the account email. Reject a bad one before anything is installed.
161 $email = isset( $params['email'] ) ? sanitize_email( $params['email'] ) : '';
162
163 if ( isset( $params['email'] ) && ! is_email( $email ) ) {
164 return $this->send_error(
165 __( 'Enter a valid email address.', 'timetics' ),
166 [ 'status' => 422 ]
167 );
168 }
169
170 $extension = timetics_extension()->find( $name );
171
172 if ( ! $extension ) {
173 return $this->send_error(
174 /* translators: %s: plugin name */
175 sprintf( __( 'Extension "%s" not found.', 'timetics' ), $name ),
176 [ 'status' => 404 ]
177 );
178 }
179
180 // Redirect for upgrade (premium) actions.
181 if ( 'upgrade' === $status ) {
182 return rest_ensure_response(
183 [
184 'success' => true,
185 'data' => [ 'redirect_url' => $extension['upgrade_link'] ],
186 'message' => __( 'Redirecting to upgrade page.', 'timetics' ),
187 ]
188 );
189 }
190
191 // All registered extensions are type=plugin — delegate to PluginManager.
192 $slug = isset( $extension['slug'] ) ? $extension['slug'] : $name;
193
194 // Our-Plugins download_url wins over the wordpress.org slug lookup, so a
195 // non-wordpress.org URL (e.g. GitHub release zip) is not shadowed.
196 $download_url = ! empty( $extension['download_url'] ) ? $extension['download_url'] : '';
197
198 // PluginManager checks no capabilities, so require what doing this by hand in Plugins needs.
199 $needs_install = 'install' === $status || ( 'activate' === $status && ! PluginManager::is_installed( $slug ) );
200
201 if ( ! current_user_can( $needs_install ? 'install_plugins' : 'activate_plugins' ) ) {
202 return $this->send_error(
203 __( 'Sorry, you are not allowed to manage plugins on this site.', 'timetics' ),
204 [ 'status' => 403 ]
205 );
206 }
207
208 switch ( $status ) {
209 case 'install':
210 if ( ! function_exists( 'WP_Filesystem' ) ) {
211 require_once ABSPATH . 'wp-admin/includes/file.php';
212 }
213 WP_Filesystem();
214 $result = $download_url
215 ? $this->install_from_url( $download_url )
216 : PluginManager::install_plugin( $slug );
217 break;
218 case 'activate':
219 // Activate can be reached on a plugin that was never installed
220 // (onboarding offers it in one click), so install on demand.
221 if ( ! PluginManager::is_installed( $slug ) ) {
222 if ( ! function_exists( 'WP_Filesystem' ) ) {
223 require_once ABSPATH . 'wp-admin/includes/file.php';
224 }
225 WP_Filesystem();
226 $install = $download_url
227 ? $this->install_from_url( $download_url )
228 : PluginManager::install_plugin( $slug );
229
230 if ( false === $install || is_wp_error( $install ) ) {
231 return $this->send_error(
232 is_wp_error( $install )
233 ? $install->get_error_message()
234 : __( 'Plugin installation failed.', 'timetics' ),
235 [ 'status' => 500 ]
236 );
237 }
238 }
239
240 $result = PluginManager::activate_plugin( $slug );
241 break;
242 case 'deactivate':
243 $result = PluginManager::deactivate_plugin( $slug );
244 break;
245 default:
246 $result = false;
247 }
248
249 if ( false === $result || is_wp_error( $result ) ) {
250 $message = is_wp_error( $result )
251 ? $result->get_error_message()
252 /* translators: %s: action name */
253 : sprintf( __( 'Could not %s the extension.', 'timetics' ), $status );
254
255 return $this->send_error( $message, [ 'status' => 500 ] );
256 }
257
258 $data = [
259 'name' => $name,
260 'status' => $status,
261 ];
262
263 /*
264 * Registration only runs when the caller sent explicit consent, which
265 * today means the onboarding checkbox, the dashboard banner button or
266 * the Ask AI setup dialog. Activating from About Us installs the plugin
267 * and stops there, so no identity leaves the site without the user
268 * opting in. Strict: a "1" or "true" string never counts as agreement.
269 */
270 if ( 'aisentic' === $name && 'activate' === $status && true === ( $params['consent'] ?? null ) && PluginManager::is_activated( $slug ) ) {
271 // Snapshot before the handshake so the caller can tell a fresh
272 // registration (tokens just granted) from re-activating a site that
273 // was already connected (no new tokens).
274 $was_registered = timetics_aisentic_is_registered();
275
276 $this->register_aisentic_site( $email );
277
278 $is_registered = timetics_aisentic_is_registered();
279
280 // The banner needs to know whether the handshake actually landed so
281 // it can show an error instead of silently disappearing.
282 $data['aisentic_registered'] = $is_registered;
283
284 // True only when this request is what connected the site, so the
285 // "150K tokens added" message never fires on a plain re-activation.
286 $data['aisentic_newly_registered'] = $is_registered && ! $was_registered;
287 }
288
289 return rest_ensure_response(
290 [
291 'success' => true,
292 'data' => $data,
293 /* translators: %s: action name */
294 'message' => sprintf( __( 'Extension %s successfully.', 'timetics' ), $status . 'd' ),
295 ]
296 );
297 }
298
299 /**
300 * Record the user's consent and hand the identity to Aisentic.
301 *
302 * Values come from timetics_aisentic_identity() so they match what the
303 * consent UI showed. Aisentic swallows provider errors and skips the call
304 * when it already has an api key, so this never affects the activation
305 * response.
306 *
307 * @param string $email Email the user typed, empty to use their account email.
308 * @return void
309 */
310 private function register_aisentic_site( $email = '' ) {
311 // Older Aisentic builds have no listener for the action below, so the
312 // handshake would go nowhere. Skip instead of storing consent for a
313 // registration that cannot happen.
314 if ( ! class_exists( 'Aisentic\Api\Services\Registration_Service' ) ) {
315 return;
316 }
317
318 $identity = timetics_aisentic_identity( $email );
319
320 // No email means nothing to register with, and Aisentic would reject
321 // the call anyway. Fail closed rather than inventing a value.
322 if ( empty( $identity['email'] ) ) {
323 return;
324 }
325
326 // Proof of consent: who agreed, when, and for which email. Also lets
327 // the banner tell "declined" apart from "never asked".
328 update_option(
329 'timetics_aisentic_consent',
330 [
331 'agreed' => true,
332 'time' => gmdate( 'c' ),
333 'user_id' => get_current_user_id(),
334 'email' => $identity['email'],
335 ],
336 false
337 );
338
339 /**
340 * Fires after the user opts in to connecting the site with Aisentic.
341 *
342 * Aisentic's Timetics integration listens for this, registers the site
343 * with its provider and marks itself connected.
344 *
345 * @param string $account_name Account name shown in the consent UI.
346 * @param string $email Account email shown in the consent UI.
347 * @param string $site_url Site URL to register with the provider.
348 */
349 do_action( 'timetics/aisentic/register_site', $identity['name'], $identity['email'], $identity['site_url'] );
350 }
351
352 /**
353 * Install a plugin from an explicit download URL.
354 *
355 * The URL must be HTTPS and its host (or a subdomain of it) must be in the
356 * trusted-domain allowlist.
357 *
358 * @param string $url Absolute HTTPS download URL.
359 * @return bool|\WP_Error True on success, WP_Error on failure.
360 */
361 private function install_from_url( string $url ) {
362 $allowed_hosts = [
363 'wordpress.org',
364 'downloads.wordpress.org',
365 'arraytics.com',
366 'themewinter.com',
367 ];
368
369 $parsed = wp_parse_url( $url );
370
371 if ( empty( $parsed['scheme'] ) || 'https' !== strtolower( $parsed['scheme'] ) || empty( $parsed['host'] ) ) {
372 return new \WP_Error(
373 'invalid_download_url',
374 __( 'Download URL must use HTTPS from a trusted domain.', 'timetics' )
375 );
376 }
377
378 $host = strtolower( $parsed['host'] );
379 $trusted = false;
380
381 foreach ( $allowed_hosts as $allowed ) {
382 if ( $host === $allowed || substr( $host, - ( strlen( $allowed ) + 1 ) ) === '.' . $allowed ) {
383 $trusted = true;
384 break;
385 }
386 }
387
388 if ( ! $trusted ) {
389 return new \WP_Error(
390 'invalid_download_url',
391 __( 'Download URL must use HTTPS from a trusted domain.', 'timetics' )
392 );
393 }
394
395 include_once ABSPATH . 'wp-admin/includes/file.php';
396 include_once ABSPATH . 'wp-admin/includes/misc.php';
397 include_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
398
399 $skin = new \Automatic_Upgrader_Skin();
400 $upgrader = new \Plugin_Upgrader( $skin );
401 $result = $upgrader->install( $url );
402
403 if ( is_wp_error( $result ) ) {
404 return $result;
405 }
406
407 return $result ? true : false;
408 }
409
410 /**
411 * Return a standardised error response.
412 *
413 * @param string $message Human-readable error message.
414 * @param array $data Additional data (e.g. ['status' => 422]).
415 * @return \WP_REST_Response
416 */
417 private function send_error( string $message, array $data = [] ): \WP_REST_Response {
418 return rest_ensure_response(
419 [
420 'success' => false,
421 'message' => $message,
422 'data' => $data,
423 ]
424 );
425 }
426 }
427