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
← All changes | core/appointments/api-appointment.php +284 -57 1.0.56 → 1.0.64 View file →
@@ -7,8 +7,10 @@
7 7 * @package Timetics
8 8 */
9 9 namespace Timetics\Core\Appointments;
10 10
11 +defined( 'ABSPATH' ) || exit;
12 +
11 13 use Timetics\Base\Api;
12 14 use Timetics\Core\Appointments\Appointment;
13 15 use Timetics\Core\Staffs\Staff;
14 16 use Timetics\Utils\Singleton;
@@ -107,9 +109,11 @@
107 109 [
108 110 'methods' => \WP_REST_Server::READABLE,
109 111 'callback' => [$this, 'search_items'],
110 112 'permission_callback' => function () {
111 - return current_user_can( 'edit_posts' );
113 + // edit_meeting is admin-only in this plugin (see get_items()) —
114 + // staff need manage_timetics to search their own meetings at all.
115 + return current_user_can( 'manage_timetics' ) || current_user_can( 'manage_options' );
112 116 },
113 117 ],
114 118 ] );
115 119
@@ -146,23 +150,64 @@
146 150 $per_page = ! empty( $request['per_page'] ) ? intval( $request['per_page'] ) : 20;
147 151 $paged = ! empty( $request['paged'] ) ? intval( $request['paged'] ) : 1;
148 152 $type = ! empty( $request['type'] ) ? sanitize_text_field( $request['type'] ) : '';
149 153
150 - $args = [
151 - 'posts_per_page' => $per_page,
152 - 'paged' => $paged,
153 - 'type' => $type,
154 - ];
154 + $restrict_to_own = ! current_user_can( 'edit_meeting' );
155 + $current_user_id = get_current_user_id();
155 156
156 - if ( ! current_user_can( 'edit_meeting' ) ) {
157 - $args['staff'] = get_current_user_id();
157 + if ( $restrict_to_own && ! $current_user_id ) {
158 + return rest_ensure_response(
159 + [
160 + 'success' => 1,
161 + 'status_code' => 200,
162 + 'data' => [
163 + 'total' => 0,
164 + 'items' => [],
165 + ],
166 + ]
167 + );
158 168 }
159 169
170 + $args = [ 'type' => $type ];
171 +
172 + if ( $restrict_to_own ) {
173 + // The staff meta_query is a LIKE match against a serialized array
174 + // and isn't safe as the access boundary (staff id 5 also matches
175 + // a meeting assigned to staff 55) — fetch broadly and enforce
176 + // real ownership below instead of filtering in SQL.
177 + $args['posts_per_page'] = -1;
178 + } else {
179 + $args['posts_per_page'] = $per_page;
180 + $args['paged'] = $paged;
181 + }
182 +
160 183 $appoint = Appointment::all( $args );
184 + $matched = $appoint['items'];
185 + $total = $appoint['total'];
161 186
187 + if ( $restrict_to_own ) {
188 + $matched = array_values(
189 + array_filter(
190 + $matched,
191 + function ( $item ) use ( $current_user_id ) {
192 + return in_array( $current_user_id, ( new Appointment( $item->ID ) )->get_staff_ids(), true );
193 + }
194 + )
195 + );
196 +
197 + $total = count( $matched );
198 +
199 + // A per_page value of -1 means "all items". Passing it directly to
200 + // array_slice() excludes the last item, which leaves a staff member
201 + // with a single assigned meeting with an empty meeting list.
202 + if ( -1 !== $per_page ) {
203 + $matched = array_slice( $matched, ( $paged - 1 ) * $per_page, $per_page );
204 + }
205 + }
206 +
162 207 $items = [];
163 208
164 - foreach ( $appoint['items'] as $item ) {
209 + foreach ( $matched as $item ) {
165 210 $items[] = $this->prepare_item( $item->ID );
166 211 }
167 212
168 213 $data = [
@@ -168,9 +213,9 @@
168 213 $data = [
169 214 'success' => 1,
170 215 'status_code' => 200,
171 216 'data' => [
172 - 'total' => $appoint['total'],
217 + 'total' => $total,
173 218 'items' => $items,
174 219 ],
175 220 ];
176 221
@@ -186,21 +231,33 @@
186 231 */
187 232 public function search_items( $request ) {
188 233
189 234 // Prepare search args.
190 - $per_page = ! empty( $request['per_page'] ) ? intval( $request['per_page'] ) : 20;
191 - $paged = ! empty( $request['paged'] ) ? intval( $request['paged'] ) : 1;
192 - $search = ! empty( $request['search'] ) ? sanitize_text_field( $request['search'] ) : '';
235 + $per_page = ! empty( $request['per_page'] ) ? intval( $request['per_page'] ) : 20;
236 + $paged = ! empty( $request['paged'] ) ? intval( $request['paged'] ) : 1;
237 + $search = ! empty( $request['search'] ) ? sanitize_text_field( $request['search'] ) : '';
238 + $restrict_to_own = ! current_user_can( 'manage_options' );
193 239
240 + $query_args = array(
241 + 'post_type' => 'timetics-appointment',
242 + 'orderby' => 'ID',
243 + 'order' => 'DESC',
244 + );
245 +
246 + if ( $restrict_to_own ) {
247 + // Same LIKE-isn't-a-boundary caveat as get_items() — fetch broadly
248 + // and enforce real ownership below instead of filtering in SQL.
249 + $query_args['posts_per_page'] = -1;
250 + } else {
251 + $query_args['posts_per_page'] = $per_page;
252 + $query_args['paged'] = $paged;
253 + }
254 +
194 255 // Get search.
195 256 $appointments = new \WP_Query(
196 - array(
197 - 'post_type' => 'timetics-appointment',
198 - 'posts_per_page' => $per_page,
199 - 'paged' => $paged,
200 - 'orderby' => 'ID',
201 - 'order' => 'DESC',
202 -
257 + array_merge(
258 + $query_args,
259 + array(
203 260 // @codingStandardsIgnoreStart
204 261 'meta_query' => array(
205 262 'relation' => 'OR',
206 263 array(
@@ -234,15 +291,38 @@
234 291 'compare' => 'LIKE',
235 292 ),
236 293 ),
237 294 // @codingStandardsIgnoreEnd
295 + )
238 296 )
239 297 );
240 298
299 + $matched = $appointments->posts;
300 + $total = $appointments->found_posts;
301 +
302 + if ( $restrict_to_own ) {
303 + $current_user_id = get_current_user_id();
304 +
305 + $matched = array_values(
306 + array_filter(
307 + $matched,
308 + function ( $item ) use ( $current_user_id ) {
309 + return in_array( $current_user_id, ( new Appointment( $item->ID ) )->get_staff_ids(), true );
310 + }
311 + )
312 + );
313 +
314 + $total = count( $matched );
315 +
316 + if ( -1 !== $per_page ) {
317 + $matched = array_slice( $matched, ( $paged - 1 ) * $per_page, $per_page );
318 + }
319 + }
320 +
241 321 // Prepare items for response.
242 322 $items = [];
243 323
244 - foreach ( $appointments->posts as $item ) {
324 + foreach ( $matched as $item ) {
245 325 $items[] = $this->prepare_item( $item->ID );
246 326 }
247 327
248 328 $data = [
@@ -248,9 +328,9 @@
248 328 $data = [
249 329 'success' => 1,
250 330 'status' => 200,
251 331 'data' => [
252 - 'total' => $appointments->found_posts,
332 + 'total' => $total,
253 333 'items' => $items,
254 334 ],
255 335 ];
256 336
@@ -260,26 +340,49 @@
260 340 public function filter_items( $request ) {
261 341
262 342 $per_page = ! empty( $request['per_page'] ) ? intval( $request['per_page'] ) : 20;
263 343 $paged = ! empty( $request['paged'] ) ? intval( $request['paged'] ) : 1;
264 - $staff = ! empty( $request['staff_id'] ) ? intval( $request['staff_id'] ) : 0;
344 + $staff = ! empty( $request['staff_id'] ) ? intval( $request['staff_id'] ) : '';
265 345 $category = ! empty( $request['category'] ) ? intval( $request['category'] ) : 0;
266 346 $visibility = ! empty( $request['visibility'] ) ? sanitize_text_field( $request['visibility'] ) : '';
267 347
348 + $restrict_to_enabled = ! current_user_can( 'edit_meeting' );
349 +
268 350 $per_page = ! empty( $request['per_page'] ) ? intval( $request['per_page'] ) : 20;
269 351 $paged = ! empty( $request['paged'] ) ? intval( $request['paged'] ) : 1;
270 352
271 353 $appoint = Appointment::all( [
272 - 'posts_per_page' => $per_page,
273 - 'paged' => $paged,
354 + 'posts_per_page' => $restrict_to_enabled ? -1 : $per_page,
355 + 'paged' => $restrict_to_enabled ? 1 : $paged,
274 356 'visibility' => $visibility,
275 357 'staff' => $staff,
276 358 'category' => $category,
277 359 ] );
278 360
361 + $matched = $appoint['items'];
362 + $total = $appoint['total'];
363 +
364 + if ( $restrict_to_enabled ) {
365 + // Public route — never show a disabled meeting type, regardless
366 + // of what visibility was requested. A blank/missing visibility
367 + // meta (legacy rows) is treated as visible, matching the default
368 + // used when saving an appointment.
369 + $matched = array_values(
370 + array_filter(
371 + $matched,
372 + function ( $item ) {
373 + return 'disabled' !== strtolower( (string) ( new Appointment( $item->ID ) )->get_visibility() );
374 + }
375 + )
376 + );
377 +
378 + $total = count( $matched );
379 + $matched = array_slice( $matched, ( $paged - 1 ) * $per_page, $per_page );
380 + }
381 +
279 382 $items = [];
280 383
281 - foreach ( $appoint['items'] as $item ) {
384 + foreach ( $matched as $item ) {
282 385 $items[] = $this->prepare_item( $item->ID );
283 386 }
284 387
285 388 $data = [
@@ -285,9 +388,9 @@
285 388 $data = [
286 389 'success' => 1,
287 390 'status' => 200,
288 391 'data' => [
289 - 'total' => $appoint['total'],
392 + 'total' => $total,
290 393 'items' => $items,
291 394 ],
292 395 ];
293 396
@@ -372,8 +475,21 @@
372 475
373 476 $appointment_id = (int) $request['appointment_id'];
374 477 $appoint = new Appointment( $appointment_id );
375 478
479 + // Handler must not rely solely on permission_callback having run.
480 + if ( ! $this->can_edit_appointment( $appointment_id ) ) {
481 + return new WP_HTTP_Response(
482 + [
483 + 'success' => 0,
484 + 'status_code' => 403,
485 + 'message' => esc_html__( 'You are not allowed to edit this appointment.', 'timetics' ),
486 + 'data' => [],
487 + ],
488 + 403
489 + );
490 + }
491 +
376 492 $data = json_decode( $request->get_body(), true );
377 493
378 494 /**
379 495 * Added temporary for leagacy sass. It will remove in future.
@@ -442,28 +558,60 @@
442 558 *
443 559 * @return bool
444 560 */
445 561 public function update_item_permissions_check( $request ) {
446 - $appoinment_id = (int) $request['appointment_id'];
447 - $appointment = new Appointment( $appoinment_id );
562 + return $this->can_edit_appointment( (int) $request['appointment_id'] );
563 + }
448 564
449 - $staff_ids = $appointment->get_staff_ids();
450 - $author = $appointment->get_author();
565 + /**
566 + * Object-level authorization for editing an appointment. Called from
567 + * both the route's permission_callback and update_item() itself, so
568 + * the handler never relies solely on the callback having run.
569 + *
570 + * @param int $appointment_id
571 + *
572 + * @return bool
573 + */
574 + private function can_edit_appointment( $appointment_id ) {
575 + if ( current_user_can( 'manage_options' ) ) {
576 + return true;
577 + }
578 +
451 579 $current_user_id = get_current_user_id();
452 580
453 - if (
454 - current_user_can( 'manage_options' )
455 - || current_user_can( 'read_meeting' )
456 - || in_array( $current_user_id, $staff_ids )
457 - || $author == $current_user_id
458 - ) {
459 - return true;
581 + if ( $current_user_id <= 0 ) {
582 + return false;
460 583 }
461 584
462 - return false;
585 + $appointment = new Appointment( $appointment_id );
586 +
587 + if ( ! $appointment->is_appointment() ) {
588 + return false;
589 + }
590 +
591 + $staff_ids = array_map( 'intval', $appointment->get_staff_ids() );
592 + $author = $appointment->get_author();
593 +
594 + // read_meeting only proves "is staff," not ownership — must not bypass the checks below.
595 + return in_array( $current_user_id, $staff_ids, true )
596 + || $author === $current_user_id;
463 597 }
464 598
465 599 /**
600 + * True only for the meeting's owner (author) or an administrator.
601 + * Used to gate fields an assigned-but-non-owning staff member must
602 + * not be able to change (staff list, visibility, webhooks).
603 + *
604 + * @param Appointment $appointment
605 + *
606 + * @return bool
607 + */
608 + private function is_appointment_owner( $appointment ) {
609 + return current_user_can( 'manage_options' )
610 + || (int) $appointment->get_author() === get_current_user_id();
611 + }
612 +
613 + /**
466 614 * Get single appointment
467 615 *
468 616 * @param WP_Rest_Requesr $request
469 617 *
@@ -483,8 +631,25 @@
483 631
484 632 return new WP_HTTP_Response( $data, 404 );
485 633 }
486 634
635 + $current_user_id = get_current_user_id();
636 + $is_privileged = current_user_can( 'edit_meeting' )
637 + || $current_user_id == $appoint->get_author()
638 + || in_array( $current_user_id, $appoint->get_staff_ids(), true );
639 +
640 + // This route is public — a disabled meeting type isn't meant to be
641 + // reachable by guessing its id, only owner/staff/admin can still see it.
642 + if ( ! $is_privileged && 'disabled' === strtolower( (string) $appoint->get_visibility() ) ) {
643 + $data = [
644 + 'status_code' => 404,
645 + 'message' => esc_html__( 'Invalid appointment id.', 'timetics' ),
646 + 'data' => [],
647 + ];
648 +
649 + return new WP_HTTP_Response( $data, 404 );
650 + }
651 +
487 652 $response = [
488 653 'status_code' => 200,
489 654 'message' => esc_html__( 'Successfully retrieved appointments', 'timetics' ),
490 655 'data' => $this->prepare_item( $appoint ),
@@ -544,23 +709,9 @@
544 709 *
545 710 * @return bool
546 711 */
547 712 public function delete_item_permissions_check( $request ) {
548 - $appoinment_id = (int) $request['appointment_id'];
549 - $appointment = new Appointment( $appoinment_id );
550 -
551 - $staff_ids = $appointment->get_staff_ids();
552 - $author = $appointment->get_author();
553 - $current_user_id = get_current_user_id();
554 -
555 - if (
556 - current_user_can( 'manage_options' )
557 - || $author == $current_user_id
558 - ) {
559 - return true;
560 - }
561 -
562 - return false;
713 + return $this->can_edit_appointment( (int) $request['appointment_id'] );
563 714 }
564 715
565 716 /**
566 717 * Delete multiples
@@ -571,12 +722,21 @@
571 722 */
572 723 public function bulk_delete( $request ) {
573 724
574 725 $appointments = json_decode( $request->get_body(), true );
726 + $appointments = is_array( $appointments ) ? $appointments : [];
575 727
576 - foreach ( $appointments as $appoint ) {
577 - $appoint = new Appointment( $appoint );
728 + $current_user_id = get_current_user_id();
729 + $is_admin = current_user_can( 'manage_options' );
578 730
731 + $to_delete = [];
732 +
733 + // Validate every id — existence and ownership — before deleting any
734 + // of them. The route only checks read_meeting, which every staff
735 + // account has, so ownership has to be enforced here per appointment.
736 + foreach ( $appointments as $appoint_id ) {
737 + $appoint = new Appointment( $appoint_id );
738 +
579 739 if ( ! $appoint->is_appointment() ) {
580 740 $data = [
581 741 'success' => 0,
582 742 'status' => 404,
@@ -586,8 +746,23 @@
586 746
587 747 return new WP_HTTP_Response( $data, 404 );
588 748 }
589 749
750 + if ( ! $is_admin && $appoint->get_author() != $current_user_id ) {
751 + $data = [
752 + 'success' => 0,
753 + 'status' => 403,
754 + 'message' => esc_html__( 'You are not allowed to delete one or more of the selected appointments.', 'timetics' ),
755 + 'data' => [],
756 + ];
757 +
758 + return new WP_HTTP_Response( $data, 403 );
759 + }
760 +
761 + $to_delete[] = $appoint;
762 + }
763 +
764 + foreach ( $to_delete as $appoint ) {
590 765 $appoint->delete();
591 766 }
592 767
593 768 return rest_ensure_response( [
@@ -698,8 +873,10 @@
698 873 $pabbly_hook_overwrite = ! empty( $data['pabbly_hook_overwrite'] ) ? (bool) $data['pabbly_hook_overwrite'] : false;
699 874 $zapier_hook_overwrite = ! empty( $data['zapier_hook_overwrite'] ) ? (bool) $data['zapier_hook_overwrite'] : false;
700 875 $pabbly_webook = ! empty( $data['pabbly_webook'] ) ? $data['pabbly_webook'] : '';
701 876 $zapier_webook = ! empty( $data['zapier_webook'] ) ? $data['zapier_webook'] : '';
877 + $flowmattic_hook_overwrite = ! empty( $data['flowmattic_hook_overwrite'] ) ? (bool) $data['flowmattic_hook_overwrite'] : false;
878 + $flowmattic_webhook = ! empty( $data['flowmattic_webhook'] ) ? esc_url_raw( $data['flowmattic_webhook'] ) : '';
702 879 $min_notice_time = ! empty( $data['min_notice_time'] ) ? $data['min_notice_time'] : '';
703 880 $custom_fields = ! empty( $data['custom_fields'] ) ? $data['custom_fields'] : [];
704 881 $guest_enabled = ! empty( $data['guest_enabled'] ) ? intval( $data['guest_enabled'] ) : false;
705 882 $guest_limit = ! empty( $data['guest_limit'] ) ? intval( $data['guest_limit'] ) : 1;
@@ -710,8 +887,24 @@
710 887 $buffer_time_before_unit = ! empty( $data['buffer_time_before_unit'] ) ? $data['buffer_time_before_unit'] : 'min';
711 888 $buffer_time_after_value = ! empty( $data['buffer_time_after_value'] ) ? $data['buffer_time_after_value'] : 0;
712 889 $buffer_time_after_unit = ! empty( $data['buffer_time_after_unit'] ) ? $data['buffer_time_after_unit'] : 'min';
713 890
891 + // Assigned-but-non-owning staff may edit their meeting's schedule/details,
892 + // but must not rename it, reassign staff, change visibility, or touch webhook integrations.
893 + if ( $id && ! $this->is_appointment_owner( $appoint ) ) {
894 + $name = $appoint->get_name();
895 + $description = $appoint->get_description();
896 + $staff = $appoint->get_staff();
897 + $visibility = $appoint->get_visibility();
898 + $notifications = $appoint->get_notifications();
899 + $fleunt_crm_webhook = $appoint->get_fleunt_crm_webhook();
900 + $fluent_hook_overwrite = $appoint->get_fluent_hook_overwrite();
901 + $pabbly_hook_overwrite = $appoint->get_pabbly_hook_overwrite();
902 + $zapier_hook_overwrite = $appoint->get_zapier_hook_overwrite();
903 + $pabbly_webook = $appoint->get_pabbly_webook();
904 + $zapier_webook = $appoint->get_zapier_webook();
905 + }
906 +
714 907 if ( $id ) {
715 908 $dulicate = $appoint->get_duplicate_nuber();
716 909 if ( $dulicate && strpos( $name, '-Duplicate' ) == 0 ) {
717 910 $appoint->update([
@@ -803,8 +996,10 @@
803 996 'pabbly_hook_overwrite' => $pabbly_hook_overwrite,
804 997 'zapier_hook_overwrite' => $zapier_hook_overwrite,
805 998 'pabbly_webook' => $pabbly_webook,
806 999 'zapier_webook' => $zapier_webook,
1000 + 'flowmattic_hook_overwrite' => $flowmattic_hook_overwrite,
1001 + 'flowmattic_webhook' => $flowmattic_webhook,
807 1002 'min_notice_time' => $min_notice_time,
808 1003 'custom_fields' => $custom_fields,
809 1004 'guest_enabled' => $guest_enabled,
810 1005 'guest_limit' => $guest_limit,
@@ -814,9 +1009,12 @@
814 1009 'buffer_time_after_unit' => $buffer_time_after_unit,
815 1010
816 1011 ];
817 1012
818 - $appointment_data = apply_filters( 'timetics_meeting_insert_data', $data, $appointment_data );
1013 + // The sanitised array is the filterable value; $data (raw body) is only
1014 + // a reference arg. Getting this order backwards silently discards every
1015 + // sanitizer/intval() above and lets the caller write arbitrary post meta.
1016 + $appointment_data = apply_filters( 'timetics_meeting_insert_data', $appointment_data, $data );
819 1017
820 1018 $appoint->set_props( $appointment_data );
821 1019 $appoint->save();
822 1020
@@ -877,8 +1075,10 @@
877 1075 'pabbly_hook_overwrite' => $appointment->get_pabbly_hook_overwrite(),
878 1076 'pabbly_webook' => $appointment->get_pabbly_webook(),
879 1077 'zapier_hook_overwrite' => $appointment->get_zapier_hook_overwrite(),
880 1078 'zapier_webook' => $appointment->get_zapier_webook(),
1079 + 'flowmattic_hook_overwrite' => $appointment->get_flowmattic_hook_overwrite(),
1080 + 'flowmattic_webhook' => $appointment->get_flowmattic_webhook(),
881 1081 'min_notice_time' => $appointment->get_min_notice_time(),
882 1082 'custom_fields' => $custom_fields ?: [],
883 1083 'permalink' => get_permalink( $appointment->get_id() ),
884 1084 'guest_enabled' => $appointment->get_guest_enabled(),
@@ -888,8 +1088,35 @@
888 1088 'buffer_time_before_unit' => $appointment->get_buffer_time_before_unit(),
889 1089 'buffer_time_after_value' => $appointment->get_buffer_time_after_value(),
890 1090 'buffer_time_after_unit' => $appointment->get_buffer_time_after_unit(),
891 1091 ];
1092 +
1093 + // Strip webhook URLs, notifications, and staff PII for non-privileged callers — several read routes here are public.
1094 + if ( ! current_user_can( 'edit_meeting' ) ) {
1095 + unset(
1096 + $data['notifications'],
1097 + $data['fluent_hook_overwrite'],
1098 + $data['fleunt_crm_webhook'],
1099 + $data['pabbly_hook_overwrite'],
1100 + $data['pabbly_webook'],
1101 + $data['zapier_hook_overwrite'],
1102 + $data['zapier_webook'],
1103 + $data['author']
1104 + );
1105 +
1106 + if ( ! empty( $data['staff'] ) && is_array( $data['staff'] ) ) {
1107 + $data['staff'] = array_map(
1108 + function ( $staff ) {
1109 + return [
1110 + 'id' => $staff['id'] ?? 0,
1111 + 'full_name' => $staff['full_name'] ?? '',
1112 + 'image' => $staff['image'] ?? '',
1113 + ];
1114 + },
1115 + $data['staff']
1116 + );
1117 + }
1118 + }
892 1119
893 1120 return apply_filters( 'timetics_meeting_json_data', $data, $appointment );
894 1121 }
895 1122