Files
unsupervised-scheduler/src/Booking/BookingEndpoint.php
T
KydoimosandClaude Opus 5 1847159e31 Fix five findings from a security assessment of the plugin
The assessment looked for three things: whether students can reach each
other's bookings, whether payment settings can be dodged, and whether the
plugin opens a way into the rest of the install. The student-isolation and
payment paths held up. These are what did not.

- The front-end login form told WordPress not to work out whether the site
  was secure, so on HTTPS every student's session cookie was issued without
  the Secure flag. wp_signon() only derives it from is_ssl() when the second
  argument is left at its default; an explicit false reads like "no
  preference" and is not.

- The update check took whatever download URL the release API returned and
  handed it to core, which unpacks it over the installed plugin. The package
  must now be https on git.unsupervised.ca exactly, compared on the parsed
  host so a lookalike name cannot pass.

- Uninstalling dropped 2 of 14 tables and left the Stripe secret and webhook
  signing key in wp_options. Removal is now a choice made in advance on
  Access -> Plugin removal: records are kept unless the owner opts in (with a
  typed confirmation), while credentials and the borrowed core registration
  settings go every time.

- Open registration switches on the site-wide users_can_register and makes
  Student the default role, arming any other signup form on the site to mint
  students who could book and be billed immediately. The pending state is now
  decided once, on user_register, rather than by whichever form created the
  account.

- Cancel and withdraw answered "not yours" differently from "does not exist",
  which let a signed-in student enumerate the studio's bookings. Both now
  give the same 404.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 11:55:53 -03:00

428 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Registration\RegistrationGate;
use Unsupervised\Schedular\Val;
class BookingEndpoint {
public function __construct(
private AvailabilityRepository $availability,
private BookingRepository $bookings,
private OfferingRepository $offerings,
private RegistrationGate $gate,
private PaymentService $payments,
private LessonBooker $booker,
private CancellationPolicy $cancellationPolicy,
private GuardianService $guardians,
private SessionSchedule $sessions,
) {}
/**
* Registers this endpoint's REST routes.
*
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
*/
public function registerRoutes( string $route_namespace ): void {
register_rest_route(
$route_namespace,
'/bookings',
[
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'myLessons' ],
'permission_callback' => [ $this, 'isLoggedIn' ],
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'book' ],
'permission_callback' => [ $this, 'canBook' ],
'args' => [
'slot_id' => [
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
],
'offering_id' => [
'type' => 'integer',
'default' => 0,
],
// Who the lesson is for. 0/absent means the caller books for
// themselves; a child's id is honoured only for their guardian.
'student_id' => [
'type' => 'integer',
'default' => 0,
'sanitize_callback' => 'absint',
],
'recurrence' => [
'type' => 'string',
'default' => 'single',
],
'answers' => [
'type' => 'object',
'default' => [],
],
'accepted_policy_version_ids' => [
'type' => 'array',
'default' => [],
],
'notes' => [
'type' => 'string',
'default' => '',
'sanitize_callback' => 'sanitize_textarea_field',
],
],
],
]
);
register_rest_route(
$route_namespace,
'/bookings/(?P<id>\d+)/cancel',
[
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'cancel' ],
'permission_callback' => [ $this, 'isLoggedIn' ],
],
]
);
register_rest_route(
$route_namespace,
'/bookings/(?P<id>\d+)/status',
[
[
'methods' => \WP_REST_Server::EDITABLE,
'callback' => [ $this, 'updateStatus' ],
'permission_callback' => [ $this, 'canManage' ],
'args' => [
'status' => [
'type' => 'string',
'required' => true,
'enum' => Lesson::VALID_STATUSES,
],
],
],
]
);
}
public function myLessons( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
$userId = get_current_user_id();
$now = current_time( 'mysql' );
// Group classes are listed here too. A term-based class has no row in
// us_availability, so nothing that only read lessons could show one, and a
// student whose whole week was a group class saw an empty schedule.
if ( current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ) {
$lessons = $this->bookings->findUpcomingForInstructor( $userId );
// One row per session the instructor teaches, not per student in it.
$sessions = array_map(
static fn( array $session ): array => $session + [ 'kind' => SessionSchedule::KIND ],
$this->sessions->upcomingForInstructor( $userId, $now )
);
} else {
// A guardian's list covers the whole household — their own lessons and
// every child's — merged and re-sorted so the soonest is first
// regardless of whose it is.
$lessons = [];
$sessions = [];
foreach ( $this->guardians->householdIds( $userId ) as $studentId ) {
$lessons = array_merge( $lessons, $this->bookings->findUpcomingForStudent( $studentId ) );
$sessions = array_merge( $sessions, $this->sessionRows( $studentId, $now ) );
}
}
$rows = array_merge(
array_map( fn( Lesson $l ): array => $this->lessonWithTimes( $l ), $lessons ),
$sessions
);
// usort reindexes in place, so the response is already a list.
usort( $rows, static fn( array $a, array $b ): int => Val::string( $a['start_dt'] ?? '' ) <=> Val::string( $b['start_dt'] ?? '' ) );
return new \WP_REST_Response( $rows, 200 );
}
/**
* One student's upcoming group-class sessions, shaped like the lesson rows
* beside them so a single list renders both. `kind` is what tells them apart:
* a session is not a booked slot, so it carries no cancel action.
*
* @return list<array<string, mixed>>
*/
private function sessionRows( int $studentId, string $now ): array {
return array_map(
fn( array $session ): array => $session + [
'kind' => SessionSchedule::KIND,
'student_name' => $this->guardians->studentName( $studentId ),
],
$this->sessions->upcomingForStudent( $studentId, $now )
);
}
/**
* A lesson's array form plus its slot's start/end times and the booked
* offering's name, so front-end lists can show what the session is and when
* it happens without a second request.
*
* @return array<string, mixed>
*/
private function lessonWithTimes( Lesson $lesson ): array {
$slot = $this->availability->findById( $lesson->slotId );
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
// Prefer the offering's own length; fall back to the slot's when the
// offering has none (a generic, duration-less type).
$duration = null !== $offering && null !== $offering->durationMinutes
? $offering->durationMinutes
: $slot?->durationMinutes;
return $lesson->toArray() + [
'start_dt' => $slot?->startDt,
'end_dt' => $slot?->endDt,
'offering_title' => $offering?->title,
'duration_minutes' => $duration,
// Whose lesson it is, so a guardian's merged list can say which child
// each row belongs to.
'student_name' => $this->guardians->studentName( $lesson->studentId ),
];
}
public function book( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
// Who the lesson is for is settled before anything else is touched: an
// unauthorised student id must never get as far as claiming a slot, and
// certainly never as far as raising a payment against someone's account.
$studentId = $this->resolveStudent( $request );
if ( $studentId instanceof \WP_Error ) {
return $studentId;
}
$slotId = Val::int( $request->get_param( 'slot_id' ) );
$slot = $this->availability->findById( $slotId );
if ( null === $slot ) {
return new \WP_Error( 'not_found', __( 'Slot not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
}
if ( $slot->isBooked ) {
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$offering = $this->booker->resolveOffering( $slot, absint( Val::int( $request->get_param( 'offering_id' ) ) ) );
if ( $offering instanceof \WP_Error ) {
return $offering;
}
$offeringId = (int) $offering->id;
$answers = $this->answers( $request );
$acceptedVersionIds = array_values( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) $request->get_param( 'accepted_policy_version_ids' ) ) );
$gateError = $this->gate->validate( $offeringId, $answers, $acceptedVersionIds );
if ( $gateError instanceof \WP_Error ) {
return $gateError;
}
$notes = Val::string( $request->get_param( 'notes' ) );
$reservation = $this->booker->reserve(
$slot,
$offering,
$studentId,
Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' ) ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE,
$notes
);
if ( $reservation instanceof \WP_Error ) {
return $reservation;
}
$ids = $reservation['ids'];
$anchorId = $reservation['anchor_id'];
// The acceptance binds the student but is attributed to whoever actually
// ticked the boxes — the guardian, when they booked for a child.
$this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp(), get_current_user_id() );
[ 'status' => $status, 'payment' => $payment ] = $this->booker->settle( $ids, $anchorId, $slot, $offering, $studentId );
// `payment: null` tells the front end to skip the payment step entirely.
return new \WP_REST_Response(
[
'ids' => $ids,
'status' => $status,
'payment' => $payment?->toSummaryArray(),
],
201
);
}
/**
* Who this booking is for: the caller by default, or one of their children
* when a `student_id` is supplied and they are that child's guardian.
*
* This is the authorisation boundary of guardian booking — without it any
* signed-in student could book, and bill, against any user id they chose to
* send. An id the caller may not act for is a 403, never a silent fallback to
* themselves: a guardian who picked the wrong child needs to be told, not to
* have the lesson quietly booked in their own name.
*/
private function resolveStudent( \WP_REST_Request $request ): int|\WP_Error {
$userId = get_current_user_id();
$requested = absint( Val::int( $request->get_param( 'student_id' ) ) );
if ( $requested <= 0 || $requested === $userId ) {
return $userId;
}
if ( ! $this->guardians->canActFor( $userId, $requested ) ) {
return new \WP_Error(
'forbidden',
__( 'You cannot book on behalf of that student.', 'unsupervised-schedular' ),
[ 'status' => 403 ]
);
}
return $requested;
}
/**
* Extract a question_id => value map from the request.
*
* @return array<int, string>
*/
private function answers( \WP_REST_Request $request ): array {
$out = [];
foreach ( (array) $request->get_param( 'answers' ) as $questionId => $value ) {
$out[ (int) $questionId ] = sanitize_text_field( Val::string( $value ) );
}
return $out;
}
private function clientIp(): ?string {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit.
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
return '' !== $ip ? $ip : null;
}
/**
* Student-initiated cancellation of their own lesson — or a guardian's, of one
* of their children's: marks it cancelled,
* frees the slot for rebooking, and voids any still-pending payment. A lesson
* already paid for is credited back to the student's account (a per-lesson
* share of the covering payment) to offset their future scheduled billing.
*/
public function cancel( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( Val::int( $request->get_param( 'id' ) ) );
$lesson = $this->bookings->findById( $id );
// A booking that is not the caller's is answered exactly as one that does
// not exist. Telling the two apart — 403 here, 404 there — would let any
// signed-in student walk the id space and learn which lessons the studio
// holds, and roughly how many. There is nothing a student can do with
// either answer, so there is no reason to distinguish them.
//
// The booking form's own 403 (see resolveStudent) is a different case: the
// student id there was chosen from a list of people the caller may act for,
// so "not yours" is a correction they need, not a fact they lack.
if ( null === $lesson || ! $this->guardians->canActFor( get_current_user_id(), $lesson->studentId ) ) {
return new \WP_Error( 'not_found', __( 'Booking not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
}
if ( Lesson::STATUS_CANCELLED !== $lesson->status ) {
$slot = $this->availability->findById( $lesson->slotId );
if ( null !== $slot ) {
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
$overrideHours = $offering?->cancellationCutoffHours;
if ( ! $this->cancellationPolicy->studentMayCancel( $slot->startDt, $overrideHours ) ) {
return new \WP_Error(
'cancellation_closed',
sprintf(
/* translators: %s: humanised cutoff window, e.g. "2 days" or "12 hours". */
__( 'This lesson can no longer be cancelled online — cancellations close %s before the lesson starts. Please contact the studio.', 'unsupervised-schedular' ),
$this->cancellationPolicy->describeCutoff( $this->cancellationPolicy->cutoffHours( $overrideHours ) )
),
[ 'status' => 403 ]
);
}
}
$this->bookings->updateStatus( $id, Lesson::STATUS_CANCELLED );
$this->availability->release( $lesson->slotId );
$this->payments->voidPending( $lesson->paymentId );
$this->payments->creditForCancelledLesson( $lesson );
}
return new \WP_REST_Response(
[
'id' => $id,
'status' => Lesson::STATUS_CANCELLED,
],
200
);
}
public function updateStatus( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( Val::int( $request->get_param( 'id' ) ) );
$lesson = $this->bookings->findById( $id );
if ( null === $lesson ) {
return new \WP_Error( 'not_found', __( 'Booking not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
}
if ( get_current_user_id() !== $lesson->instructorId && ! current_user_can( 'manage_options' ) ) {
return new \WP_Error( 'forbidden', __( 'You cannot update this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
$status = Val::string( $request->get_param( 'status' ) );
if ( Lesson::STATUS_CANCELLED === $status && Lesson::STATUS_CANCELLED !== $lesson->status ) {
$this->availability->release( $lesson->slotId );
$this->payments->voidPending( $lesson->paymentId );
$this->payments->creditForCancelledLesson( $lesson );
} elseif ( Lesson::STATUS_CANCELLED === $lesson->status && Lesson::STATUS_CANCELLED !== $status && ! $this->availability->claim( $lesson->slotId ) ) {
// Reinstating a cancelled lesson must re-reserve its slot, and
// someone else may have booked the freed time in the meantime.
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$this->bookings->updateStatus( $id, $status );
return new \WP_REST_Response(
[
'id' => $id,
'status' => $status,
],
200
);
}
public function isLoggedIn(): bool {
return is_user_logged_in();
}
public function canBook(): bool {
return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON );
}
public function canManage(): bool {
return is_user_logged_in() && (
current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) || current_user_can( 'manage_options' )
);
}
}