PluginProbe
Timetics – Appointment Booking Calendar & Scheduling / trunk
Timetics – Appointment Booking Calendar & Scheduling vtrunk
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 1.0.22 1.0.23 1.0.24 All 62 releases
timetics / core / addon / api-addon.php

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

405 lines 13.7 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 $extension = timetics_extension()->find( $name );
161
162 if ( ! $extension ) {
163 return $this->send_error(
164 /* translators: %s: plugin name */
165 sprintf( __( 'Extension "%s" not found.', 'timetics' ), $name ),
166 [ 'status' => 404 ]
167 );
168 }
169
170 // Redirect for upgrade (premium) actions.
171 if ( 'upgrade' === $status ) {
172 return rest_ensure_response(
173 [
174 'success' => true,
175 'data' => [ 'redirect_url' => $extension['upgrade_link'] ],
176 'message' => __( 'Redirecting to upgrade page.', 'timetics' ),
177 ]
178 );
179 }
180
181 // All registered extensions are type=plugin — delegate to PluginManager.
182 $slug = isset( $extension['slug'] ) ? $extension['slug'] : $name;
183
184 // Our-Plugins download_url wins over the wordpress.org slug lookup, so a
185 // non-wordpress.org URL (e.g. GitHub release zip) is not shadowed.
186 $download_url = ! empty( $extension['download_url'] ) ? $extension['download_url'] : '';
187
188 switch ( $status ) {
189 case 'install':
190 if ( ! function_exists( 'WP_Filesystem' ) ) {
191 require_once ABSPATH . 'wp-admin/includes/file.php';
192 }
193 WP_Filesystem();
194 $result = $download_url
195 ? $this->install_from_url( $download_url )
196 : PluginManager::install_plugin( $slug );
197 break;
198 case 'activate':
199 // Activate can be reached on a plugin that was never installed
200 // (onboarding offers it in one click), so install on demand.
201 if ( ! PluginManager::is_installed( $slug ) ) {
202 if ( ! function_exists( 'WP_Filesystem' ) ) {
203 require_once ABSPATH . 'wp-admin/includes/file.php';
204 }
205 WP_Filesystem();
206 $install = $download_url
207 ? $this->install_from_url( $download_url )
208 : PluginManager::install_plugin( $slug );
209
210 if ( false === $install || is_wp_error( $install ) ) {
211 return $this->send_error(
212 is_wp_error( $install )
213 ? $install->get_error_message()
214 : __( 'Plugin installation failed.', 'timetics' ),
215 [ 'status' => 500 ]
216 );
217 }
218 }
219
220 $result = PluginManager::activate_plugin( $slug );
221 break;
222 case 'deactivate':
223 $result = PluginManager::deactivate_plugin( $slug );
224 break;
225 default:
226 $result = false;
227 }
228
229 if ( false === $result || is_wp_error( $result ) ) {
230 $message = is_wp_error( $result )
231 ? $result->get_error_message()
232 /* translators: %s: action name */
233 : sprintf( __( 'Could not %s the extension.', 'timetics' ), $status );
234
235 return $this->send_error( $message, [ 'status' => 500 ] );
236 }
237
238 $data = [
239 'name' => $name,
240 'status' => $status,
241 ];
242
243 /*
244 * Registration only runs when the caller sent explicit consent, which
245 * today means the onboarding checkbox or the dashboard banner button.
246 * Activating from About Us installs the plugin and stops there, so no
247 * identity leaves the site without the user opting in.
248 */
249 if ( 'aisentic' === $name && 'activate' === $status && ! empty( $params['consent'] ) && PluginManager::is_activated( $slug ) ) {
250 // Snapshot before the handshake so the caller can tell a fresh
251 // registration (tokens just granted) from re-activating a site that
252 // was already connected (no new tokens).
253 $was_registered = timetics_aisentic_is_registered();
254
255 $this->register_aisentic_site();
256
257 $is_registered = timetics_aisentic_is_registered();
258
259 // The banner needs to know whether the handshake actually landed so
260 // it can show an error instead of silently disappearing.
261 $data['aisentic_registered'] = $is_registered;
262
263 // True only when this request is what connected the site, so the
264 // "150K tokens added" message never fires on a plain re-activation.
265 $data['aisentic_newly_registered'] = $is_registered && ! $was_registered;
266 }
267
268 return rest_ensure_response(
269 [
270 'success' => true,
271 'data' => $data,
272 /* translators: %s: action name */
273 'message' => sprintf( __( 'Extension %s successfully.', 'timetics' ), $status . 'd' ),
274 ]
275 );
276 }
277
278 /**
279 * Record the user's consent and hand the identity to Aisentic.
280 *
281 * Values come from timetics_aisentic_identity() so they match what the
282 * consent UI showed. Aisentic swallows provider errors and skips the call
283 * when it already has an api key, so this never affects the activation
284 * response.
285 *
286 * @return void
287 */
288 private function register_aisentic_site() {
289 // Older Aisentic builds have no listener for the action below, so the
290 // handshake would go nowhere. Skip instead of storing consent for a
291 // registration that cannot happen.
292 if ( ! class_exists( 'Aisentic\Api\Services\Registration_Service' ) ) {
293 return;
294 }
295
296 $identity = timetics_aisentic_identity();
297
298 // No email means nothing to register with, and Aisentic would reject
299 // the call anyway. Fail closed rather than inventing a value.
300 if ( empty( $identity['email'] ) ) {
301 return;
302 }
303
304 // Proof of consent: who agreed, when, and for which email. Also lets
305 // the banner tell "declined" apart from "never asked".
306 update_option(
307 'timetics_aisentic_consent',
308 [
309 'agreed' => true,
310 'time' => gmdate( 'c' ),
311 'user_id' => get_current_user_id(),
312 'email' => $identity['email'],
313 ],
314 false
315 );
316
317 /**
318 * Fires after the user opts in to connecting the site with Aisentic.
319 *
320 * Aisentic's Timetics integration listens for this, registers the site
321 * with its provider and marks itself connected.
322 *
323 * @param string $account_name Account name shown in the consent UI.
324 * @param string $email Account email shown in the consent UI.
325 * @param string $site_url Site URL to register with the provider.
326 */
327 do_action( 'timetics/aisentic/register_site', $identity['name'], $identity['email'], $identity['site_url'] );
328 }
329
330 /**
331 * Install a plugin from an explicit download URL.
332 *
333 * The URL must be HTTPS and its host (or a subdomain of it) must be in the
334 * trusted-domain allowlist.
335 *
336 * @param string $url Absolute HTTPS download URL.
337 * @return bool|\WP_Error True on success, WP_Error on failure.
338 */
339 private function install_from_url( string $url ) {
340 $allowed_hosts = [
341 'wordpress.org',
342 'downloads.wordpress.org',
343 'arraytics.com',
344 'themewinter.com',
345 ];
346
347 $parsed = wp_parse_url( $url );
348
349 if ( empty( $parsed['scheme'] ) || 'https' !== strtolower( $parsed['scheme'] ) || empty( $parsed['host'] ) ) {
350 return new \WP_Error(
351 'invalid_download_url',
352 __( 'Download URL must use HTTPS from a trusted domain.', 'timetics' )
353 );
354 }
355
356 $host = strtolower( $parsed['host'] );
357 $trusted = false;
358
359 foreach ( $allowed_hosts as $allowed ) {
360 if ( $host === $allowed || substr( $host, - ( strlen( $allowed ) + 1 ) ) === '.' . $allowed ) {
361 $trusted = true;
362 break;
363 }
364 }
365
366 if ( ! $trusted ) {
367 return new \WP_Error(
368 'invalid_download_url',
369 __( 'Download URL must use HTTPS from a trusted domain.', 'timetics' )
370 );
371 }
372
373 include_once ABSPATH . 'wp-admin/includes/file.php';
374 include_once ABSPATH . 'wp-admin/includes/misc.php';
375 include_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
376
377 $skin = new \Automatic_Upgrader_Skin();
378 $upgrader = new \Plugin_Upgrader( $skin );
379 $result = $upgrader->install( $url );
380
381 if ( is_wp_error( $result ) ) {
382 return $result;
383 }
384
385 return $result ? true : false;
386 }
387
388 /**
389 * Return a standardised error response.
390 *
391 * @param string $message Human-readable error message.
392 * @param array $data Additional data (e.g. ['status' => 422]).
393 * @return \WP_REST_Response
394 */
395 private function send_error( string $message, array $data = [] ): \WP_REST_Response {
396 return rest_ensure_response(
397 [
398 'success' => false,
399 'message' => $message,
400 'data' => $data,
401 ]
402 );
403 }
404 }
405