action_service === null ) { $this->action_service = $this->container->get_event_action_service(); } return $this->action_service; } /** * Get the event transformer (lazy-loaded). */ private function transformer() : EventTransformer { if ( $this->transformer === null ) { $this->transformer = $this->container->get_event_transformer(); } return $this->transformer; } /** * Get the conflict service (lazy-loaded). */ private function conflict_service() : \WebCalendar\WP\ConflictService { if ( $this->conflict_service === null ) { $this->conflict_service = $this->container->get_conflict_service(); } return $this->conflict_service; } /** * Record an activity log entry (best-effort — does not throw on failure). * * @psalm-suppress UnusedMethod -- Called from create_event, update_event, delete_event. */ private function audit_log( int $entry_id, string $login, \WebCalendar\Core\Domain\ValueObject\ActivityLogType $type, string $text = '' ) : void { try { /** @var \WebCalendar\Core\Application\Service\ActivityLogService $log_service */ $log_service = $this->container->get( 'activity_log_service' ); $log_service->log( $entry_id, $login, null, $type, $text ); } catch ( \Throwable $e ) { $this->get_logger()->warning( 'Audit log failed: ' . $e->getMessage() ); } } /** * Build the args schema shared by POST /events and PUT /events/{id}. * * @param bool $require_core_fields When true (POST), title and start * are required. When false (PUT), * every field is optional so * partial updates are accepted. * * @return array> * * @psalm-suppress UnusedMethod -- Invoked during register_routes(); * Psalm cannot trace calls made inside array values. */ private function event_write_args( bool $require_core_fields ) : array { $array_of_logins = [ 'required' => false, 'type' => 'array', 'default' => [], 'sanitize_callback' => static function ( $value ) : array { if ( !is_array( $value ) ) { return []; } $out = []; foreach ( $value as $item ) { $login = sanitize_user( ( is_scalar( $item ) ? (string) $item : '' ), true ); if ( $login !== '' ) { $out[] = $login; } } return array_values( array_unique( $out ) ); }, 'validate_callback' => static fn( $value ): bool => is_array( $value ), ]; $array_plain = [ 'required' => false, 'type' => 'array', 'default' => [], 'sanitize_callback' => static fn( $value ): array => ( is_array( $value ) ? $value : [] ), 'validate_callback' => static fn( $value ): bool => is_array( $value ) || $value === null, ]; $duration = [ 'required' => false, 'type' => 'integer', 'default' => 60, 'sanitize_callback' => 'absint', 'validate_callback' => static fn( $value ): bool => (is_int( $value ) || is_string( $value ) && ctype_digit( $value )) && (int) $value >= 0, ]; return [ 'title' => $this->rest_arg_string( 200, $require_core_fields ), 'start' => $this->rest_arg_iso_date( $require_core_fields ), 'description' => $this->rest_arg_rich_text(), 'location' => $this->rest_arg_string( 500, false ), 'duration' => $duration, 'allDay' => $this->rest_arg_bool( false ), 'access' => $this->rest_arg_access_level(), 'rrule' => $this->rest_arg_rrule(), 'category_id' => $this->rest_arg_positive_int( false ), 'participants' => $array_of_logins, 'force' => $this->rest_arg_bool( false ), 'attachments' => $array_plain, 'featured_image_id' => $this->rest_arg_positive_int( false ), 'isInstanceEdit' => $this->rest_arg_bool( false ), 'originalEventId' => $this->rest_arg_positive_int( false ), 'instanceDate' => $this->rest_arg_iso_date( false ), ]; } /** * Args entry for the path-captured `id` parameter on /events/{id}. * * @return array> * * @psalm-suppress UnusedMethod -- Invoked during register_routes(); * Psalm cannot trace calls made inside array values. */ private function event_id_arg() : array { return [ 'id' => [ 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => static fn( $value ): bool => is_numeric( $value ) && (int) $value > 0, ], ]; } /** * Register the routes. */ public function register_routes() : void { register_rest_route( $this->namespace, '/events', [[ 'methods' => 'GET', 'callback' => [$this, 'get_events'], 'permission_callback' => [$this, 'get_events_permissions_check'], 'args' => [ 'start_date' => [ 'required' => true, 'validate_callback' => [$this, 'validate_date'], ], 'end_date' => [ 'required' => true, 'validate_callback' => [$this, 'validate_date'], ], 'users' => [ 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ], 'page' => [ 'required' => false, 'default' => 1, 'sanitize_callback' => 'absint', ], 'per_page' => [ 'required' => false, 'default' => 500, 'sanitize_callback' => 'absint', ], 'category' => [ 'required' => false, 'sanitize_callback' => 'absint', ], '_fields' => [ 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ], ], ], [ 'methods' => 'POST', 'callback' => [$this, 'create_event'], 'permission_callback' => [$this, 'create_event_permissions_check'], 'args' => $this->event_write_args( true ), ]] ); register_rest_route( $this->namespace, '/events/export', [[ 'methods' => 'GET', 'callback' => [$this, 'export_events'], 'permission_callback' => [$this, 'get_events_permissions_check'], ]] ); register_rest_route( $this->namespace, '/events/export/csv', [[ 'methods' => 'GET', 'callback' => [$this, 'export_events_csv'], 'permission_callback' => [$this, 'export_csv_permissions_check'], ]] ); register_rest_route( $this->namespace, '/events/(?P\\d+)', [[ 'methods' => 'GET', 'callback' => [$this, 'get_event'], 'permission_callback' => [$this, 'get_event_permissions_check'], ], [ 'methods' => 'PUT', 'callback' => [$this, 'update_event'], 'permission_callback' => [$this, 'update_event_permissions_check'], 'args' => array_merge( $this->event_id_arg(), $this->event_write_args( false ) ), ], [ 'methods' => 'DELETE', 'callback' => [$this, 'delete_event'], 'permission_callback' => [$this, 'delete_event_permissions_check'], 'args' => $this->event_id_arg(), ]] ); register_rest_route( $this->namespace, '/events/(?P\\d+)/export', [[ 'methods' => 'GET', 'callback' => [$this, 'export_single_event'], 'permission_callback' => [$this, 'get_event_permissions_check'], ]] ); } /** * Check permissions for getting a single event. */ public function get_event_permissions_check( WP_REST_Request $request ) : bool|\WP_Error { $rate_limit = $this->check_rate_limit(); if ( is_wp_error( $rate_limit ) ) { return $rate_limit; } if ( current_user_can( 'webcal_can_view_all_events' ) ) { return true; } $id = $this->int( $request->get_param( 'id' ) ); $event_service = $this->container->get_event_service(); $event = $event_service->getEventById( new \WebCalendar\Core\Domain\ValueObject\EventId($id) ); if ( !$event ) { return true; // Let get_event handle 404 } // Allow anonymous access to public events (consistent with GET /events) if ( $event->access() === \WebCalendar\Core\Domain\ValueObject\AccessLevel::PUBLIC ) { return true; } if ( !is_user_logged_in() ) { return new \WP_Error('rest_forbidden', __( 'You do not have permission to view this event.', 'agenticdaisy-calendar' ), [ 'status' => 403, ]); } if ( $event->createdBy() === wp_get_current_user()->user_login ) { return true; } $participant_repo = $this->container->get_participant_repository(); $participants = $participant_repo->get_participants( $id ); if ( in_array( wp_get_current_user()->user_login, $participants, true ) ) { return true; } return new \WP_Error('rest_forbidden', __( 'You do not have permission to view this event.', 'agenticdaisy-calendar' ), [ 'status' => 403, ]); } /** * Get a single event. */ public function get_event( WP_REST_Request $request ) : WP_REST_Response|WP_Error { $id = $this->int( $request->get_param( 'id' ) ); try { $event_service = $this->container->get_event_service(); $event = $event_service->getEventById( new \WebCalendar\Core\Domain\ValueObject\EventId($id) ); if ( !$event ) { return new WP_Error('webcal_not_found', __( 'Event not found', 'agenticdaisy-calendar' ), [ 'status' => 404, ]); } return new WP_REST_Response($this->transformer()->transform_event( $event ), 200); } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in EventsController (get_event): ' . $e->getMessage() ); return new WP_Error('webcal_error', $e->getMessage(), [ 'status' => 500, ]); } } /** * Check permissions for updating events. */ public function update_event_permissions_check( WP_REST_Request $request ) : bool|\WP_Error { $rate_limit = $this->check_rate_limit(); if ( is_wp_error( $rate_limit ) ) { return $rate_limit; } if ( current_user_can( 'webcal_can_edit_others' ) ) { return true; } $id = $this->int( $request->get_param( 'id' ) ); $event_service = $this->container->get_event_service(); $event = $event_service->getEventById( new \WebCalendar\Core\Domain\ValueObject\EventId($id) ); if ( !$event ) { return true; // Let update_event handle 404 } if ( $event->createdBy() === wp_get_current_user()->user_login ) { if ( !current_user_can( 'webcal_can_edit' ) ) { return new \WP_Error('rest_forbidden', __( 'You do not have permission to edit events.', 'agenticdaisy-calendar' ), [ 'status' => 403, ]); } return true; } return new \WP_Error('rest_forbidden', __( 'You do not have permission to edit this event.', 'agenticdaisy-calendar' ), [ 'status' => 403, ]); } /** * Update an event. */ public function update_event( WP_REST_Request $request ) : WP_REST_Response|WP_Error { $id = $this->int( $request->get_param( 'id' ) ); /** @var array $params */ $params = $this->safe_params( $request ); /** @var array $params */ $params = apply_filters( 'webcal_pre_update_event', $params, $id ); try { $event_service = $this->container->get_event_service(); $existing = $event_service->getEventById( new \WebCalendar\Core\Domain\ValueObject\EventId($id) ); if ( !$existing ) { return new WP_Error('webcal_not_found', __( 'Event not found', 'agenticdaisy-calendar' ), [ 'status' => 404, ]); } $recurrence = $existing->recurrence(); if ( isset( $params['rrule'] ) ) { $rrule_str = $this->str( $params['rrule'] ); $recurrence = ( empty( $rrule_str ) ? new \WebCalendar\Core\Domain\ValueObject\Recurrence() : new \WebCalendar\Core\Domain\ValueObject\Recurrence(new \WebCalendar\Core\Domain\ValueObject\RecurrenceRule($rrule_str)) ); } $is_all_day = ( isset( $params['allDay'] ) ? !empty( $params['allDay'] ) : $existing->isAllDay() ); $event = new \WebCalendar\Core\Domain\Entity\Event( id: new \WebCalendar\Core\Domain\ValueObject\EventId($id), uid: $existing->uid(), // Params arriving here have already been run through the PUT // route's args schema (see event_write_args(), WPORG-7.3) when // the request comes through rest_do_request(). The outer // sanitize_text_field()/wp_kses_post() wrappers are now gone // because the schema does that work; $this->str()/$this->int() // stay as the PHPStan-Level-9 mixed→narrow coercion. name: $this->str( $params['title'] ?? $existing->name() ), description: $this->str( $params['description'] ?? $existing->description() ), location: $this->str( $params['location'] ?? $existing->location() ), start: new \DateTimeImmutable($this->str( $params['start'] ?? $existing->start()->format( 'c' ) )), duration: ( $is_all_day ? 1440 : $this->int( $params['duration'] ?? $existing->duration() ) ), createdBy: $existing->createdBy(), type: $existing->type(), access: ( isset( $params['access'] ) ? $this->action_service()->parse_access_level( $this->str( $params['access'] ) ) : $existing->access() ), recurrence: $recurrence, allDay: $is_all_day ); // Conflict detection (Starter+ only). The whole premium block — // including the tier check — lives inside can_use_premium_code so it // is stripped from the free build; no license check ships to wp.org. if ( empty( $params['force'] ) ) { } $event_service->updateEvent( $event, $this->get_current_actor() ); $this->audit_log( $id, wp_get_current_user()->user_login, \WebCalendar\Core\Domain\ValueObject\ActivityLogType::UPDATE, $event->name() ); do_action( 'webcal_event_updated', $id, $event, wp_get_current_user()->user_login ); // Handle category association if ( isset( $params['category_id'] ) ) { $cat_id = $this->int( $params['category_id'] ); if ( $cat_id > 0 ) { $this->action_service()->save_category( $id, $cat_id ); } else { $this->action_service()->delete_category( $id ); } } // Handle participants if ( isset( $params['participants'] ) ) { $participant_repo = $this->container->get_participant_repository(); $participant_param = $params['participants']; /** @var string[] $participant_list */ $participant_list = ( is_array( $participant_param ) ? array_map( fn( $v ) => (string) (( is_scalar( $v ) ? $v : '' )), $participant_param ) : [] ); $participant_repo->save_participants( $id, $participant_list ); // Notify participants $this->action_service()->notify_participants( $event, $participant_list ); } // Handle attachments (stored as WP option keyed by event ID) if ( isset( $params['attachments'] ) ) { if ( empty( $params['attachments'] ) ) { delete_option( 'webcal_attachments_' . $id ); } else { update_option( 'webcal_attachments_' . $id, $params['attachments'] ); } } // Handle featured image if ( array_key_exists( 'featured_image_id', $params ) ) { $fi_id = $this->int( $params['featured_image_id'] ?? 0 ); if ( $fi_id > 0 ) { update_option( 'webcal_featured_image_' . $id, $fi_id, false ); } else { delete_option( 'webcal_featured_image_' . $id ); } } return new WP_REST_Response([ 'id' => $id, ], 200); } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in EventsController (update_event): ' . $e->getMessage() ); return new WP_Error('webcal_error', $e->getMessage(), [ 'status' => 500, ]); } } /** * Export events to iCal. */ public function export_events( WP_REST_Request $request ) : WP_Error|\WP_REST_Response { // WPORG-11.1: wrap the REST input in the canonical // sanitize_text_field( wp_unslash() ) chain so static scanners // see a recognised WP sanitizer on the path from user input to // wp_verify_nonce(). $nonce = sanitize_text_field( wp_unslash( $this->str( $request->get_param( '_wpnonce' ) ) ) ); if ( !wp_verify_nonce( $nonce, 'wp_rest' ) ) { return new WP_Error('rest_forbidden', __( 'Invalid nonce.', 'agenticdaisy-calendar' ), [ 'status' => 403, ]); } $start_date = $this->str( $request->get_param( 'start_date' ) ); $end_date = $this->str( $request->get_param( 'end_date' ) ); try { $event_service = $this->container->get_event_service(); $export_svc = $this->container->get_export_service(); $category_repo = $this->container->get_category_repository(); if ( !empty( $start_date ) && !empty( $end_date ) ) { $range = new DateRange(new DateTimeImmutable($start_date), new DateTimeImmutable($end_date)); $events = $event_service->getEventsInDateRange( $range ); } else { $now = new \DateTimeImmutable(); $range = new DateRange($now->modify( '-1 year' ), $now->modify( '+1 year' )); $events = $event_service->getEventsInDateRange( $range ); } // Build category name map for export. Categories belong to the event's // creator (see get_events()), so read them as the creator, not the viewer. $category_map = []; foreach ( $events as $event ) { $event_id = $event->id()->value(); $categories = $category_repo->getForEvent( $event->id(), $event->createdBy() ); if ( !empty( $categories ) ) { $category_map[$event_id] = array_map( fn( $cat ) => $cat->name(), $categories ); } } $ics = $export_svc->exportIcal( $events, $category_map ); $response = new WP_REST_Response($ics, 200); $response->header( 'Content-Type', 'text/calendar; charset=utf-8' ); $response->header( 'Content-Disposition', 'attachment; filename="webcalendar-export.ics"' ); return $response; } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in EventsController (export_events): ' . $e->getMessage() ); return new WP_Error('webcal_error', $e->getMessage(), [ 'status' => 500, ]); } } /** * Export events as CSV. */ public function export_events_csv( WP_REST_Request $request ) : WP_Error|\WP_REST_Response { // WPORG-11.1: sanitize_text_field( wp_unslash() ) chain on the // nonce input; see export_events() for rationale. $nonce = sanitize_text_field( wp_unslash( $this->str( $request->get_param( '_wpnonce' ) ) ) ); if ( !wp_verify_nonce( $nonce, 'wp_rest' ) ) { return new WP_Error('rest_forbidden', __( 'Invalid nonce.', 'agenticdaisy-calendar' ), [ 'status' => 403, ]); } $start_date = $this->str( $request->get_param( 'start_date' ) ); $end_date = $this->str( $request->get_param( 'end_date' ) ); try { $event_service = $this->container->get_event_service(); $category_repo = $this->container->get_category_repository(); if ( !empty( $start_date ) && !empty( $end_date ) ) { $range = new DateRange(new DateTimeImmutable($start_date), new DateTimeImmutable($end_date)); } else { $now = new \DateTimeImmutable(); $range = new DateRange($now->modify( '-1 year' ), $now->modify( '+1 year' )); } $events = $event_service->getEventsInDateRange( $range ); // Build category name map. Categories belong to the event's creator // (see get_events()), so read them as the creator, not the viewer. $category_map = []; foreach ( $events as $event ) { $event_id = $event->id()->value(); $categories = $category_repo->getForEvent( $event->id(), $event->createdBy() ); if ( !empty( $categories ) ) { $category_map[$event_id] = $categories[0]->name(); } } // Build participant map $event_ids = array_map( fn( $e ) => $e->id()->value(), iterator_to_array( $events ) ); $participant_repo = $this->container->get_participant_repository(); $participants_map = $participant_repo->get_participants_batch( $event_ids ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- in-memory stream for CSV generation, not a real file $output = fopen( 'php://temp', 'w+' ); if ( $output === false ) { return new WP_Error('webcal_error', __( 'Failed to open output stream.', 'agenticdaisy-calendar' ), [ 'status' => 500, ]); } // UTF-8 BOM for Excel compatibility // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- in-memory php://temp stream, not a real file fwrite( $output, "" ); fputcsv( $output, [ __( 'Title', 'agenticdaisy-calendar' ), __( 'Start', 'agenticdaisy-calendar' ), __( 'End', 'agenticdaisy-calendar' ), __( 'All Day', 'agenticdaisy-calendar' ), __( 'Location', 'agenticdaisy-calendar' ), __( 'Description', 'agenticdaisy-calendar' ), __( 'Category', 'agenticdaisy-calendar' ), __( 'Created By', 'agenticdaisy-calendar' ), __( 'Access', 'agenticdaisy-calendar' ), __( 'Recurrence Rule', 'agenticdaisy-calendar' ), __( 'Participants', 'agenticdaisy-calendar' ), __( 'URL', 'agenticdaisy-calendar' ) ] ); foreach ( $events as $event ) { $eid = $event->id()->value(); $transformed = $this->transformer()->transform_event( $event ); $description = wp_strip_all_tags( $event->description() ); $category = $category_map[$eid] ?? ''; $participants = $participants_map[$eid] ?? []; $start_val = ( is_string( $transformed['start'] ) ? $transformed['start'] : '' ); $end_val = ( is_string( $transformed['end'] ) ? $transformed['end'] : '' ); $url_val = ( isset( $transformed['url'] ) && is_string( $transformed['url'] ) ? $transformed['url'] : '' ); /** @var array $row */ $row = [ $event->name(), $start_val, $end_val, ( $event->isAllDay() ? __( 'Yes', 'agenticdaisy-calendar' ) : __( 'No', 'agenticdaisy-calendar' ) ), $event->location(), $description, $category, $event->createdBy(), $event->access()->value, $event->recurrence()->rule()?->toString() ?? '', implode( ', ', $participants ), $url_val ]; fputcsv( $output, $row ); } rewind( $output ); $csv_content = stream_get_contents( $output ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- in-memory stream, not a real file fclose( $output ); $response = new WP_REST_Response($csv_content, 200); $response->header( 'Content-Type', 'text/csv; charset=utf-8' ); $response->header( 'Content-Disposition', 'attachment; filename="webcalendar-export.csv"' ); return $response; } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in EventsController (export_events_csv): ' . $e->getMessage() ); return new WP_Error('webcal_error', $e->getMessage(), [ 'status' => 500, ]); } } /** * Export a single event as an ICS file download. */ public function export_single_event( WP_REST_Request $request ) : WP_Error|\WP_REST_Response { // WPORG-11.1: sanitize_text_field( wp_unslash() ) chain on the // nonce input; see export_events() for rationale. $nonce = sanitize_text_field( wp_unslash( $this->str( $request->get_param( '_wpnonce' ) ) ) ); if ( !wp_verify_nonce( $nonce, 'wp_rest' ) ) { return new WP_Error('rest_forbidden', __( 'Invalid nonce.', 'agenticdaisy-calendar' ), [ 'status' => 403, ]); } $id = $this->int( $request->get_param( 'id' ) ); try { $event_service = $this->container->get_event_service(); $export_svc = $this->container->get_export_service(); $category_repo = $this->container->get_category_repository(); $event = $event_service->getEventById( new \WebCalendar\Core\Domain\ValueObject\EventId($id) ); if ( !$event ) { return new WP_Error('webcal_not_found', __( 'Event not found', 'agenticdaisy-calendar' ), [ 'status' => 404, ]); } $collection = new \WebCalendar\Core\Domain\ValueObject\EventCollection([$event]); // Build category map for this event. Categories belong to the event's // creator (see get_events()), so read them as the creator, not the viewer. $category_map = []; $categories = $category_repo->getForEvent( $event->id(), $event->createdBy() ); if ( !empty( $categories ) ) { $category_map[$id] = array_map( fn( $cat ) => $cat->name(), $categories ); } $ics = $export_svc->exportIcal( $collection, $category_map ); // Sanitize event name for filename $safe_name = sanitize_file_name( $event->name() ); $filename = ( $safe_name ? $safe_name . '.ics' : 'event-' . $id . '.ics' ); $response = new WP_REST_Response($ics, 200); $response->header( 'Content-Type', 'text/calendar; charset=utf-8' ); $response->header( 'Content-Disposition', 'attachment; filename="' . $filename . '"' ); return $response; } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in EventsController (export_single): ' . $e->getMessage() ); return new WP_Error('webcal_error', $e->getMessage(), [ 'status' => 500, ]); } } /** * Check permissions for deleting events. */ public function delete_event_permissions_check( WP_REST_Request $request ) : bool|\WP_Error { $rate_limit = $this->check_rate_limit(); if ( is_wp_error( $rate_limit ) ) { return $rate_limit; } if ( current_user_can( 'webcal_can_delete_others' ) ) { return true; } $id = $this->int( $request->get_param( 'id' ) ); $event_service = $this->container->get_event_service(); $event = $event_service->getEventById( new \WebCalendar\Core\Domain\ValueObject\EventId($id) ); if ( !$event ) { return true; // Let delete_event handle 404 } if ( $event->createdBy() === wp_get_current_user()->user_login ) { return current_user_can( 'webcal_can_delete' ); } return new \WP_Error('rest_forbidden', __( 'You do not have permission to delete this event.', 'agenticdaisy-calendar' ), [ 'status' => 403, ]); } /** * Delete an event. */ public function delete_event( WP_REST_Request $request ) : WP_REST_Response|WP_Error { $id = $this->int( $request->get_param( 'id' ) ); try { $event_service = $this->container->get_event_service(); $event_service->deleteEvent( new \WebCalendar\Core\Domain\ValueObject\EventId($id), $this->get_current_actor() ); $this->audit_log( $id, wp_get_current_user()->user_login, \WebCalendar\Core\Domain\ValueObject\ActivityLogType::UPDATE ); do_action( 'webcal_event_deleted', $id, wp_get_current_user()->user_login ); // Clean up attachment and featured image data delete_option( 'webcal_attachments_' . $id ); delete_option( 'webcal_featured_image_' . $id ); return new WP_REST_Response(null, 204); } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in EventsController (delete_event): ' . $e->getMessage() ); return new WP_Error('webcal_error', $e->getMessage(), [ 'status' => 500, ]); } } /** * Validate a date string. */ public function validate_date( mixed $value ) : bool { return (bool) strtotime( $this->str( $value ) ); } /** * Check permissions for getting events. */ public function get_events_permissions_check( WP_REST_Request $request ) : bool|\WP_Error { $rate_limit = $this->check_rate_limit(); if ( is_wp_error( $rate_limit ) ) { return $rate_limit; } // Allow public read access — get_events() filters by access level if ( !is_user_logged_in() ) { return true; } if ( current_user_can( 'webcal_can_view_all_events' ) ) { return true; } return $this->check_permission( 'webcal_can_view' ); } /** * Permission callback for CSV bulk export. * * CSV export is an admin-grade operation; unlike the ICS subscription * feed (which is a legitimate public-read endpoint), CSV export dumps * a spreadsheet of the calendar and has no public-consumption use * case. The WP.org review team flagged re-using * `get_events_permissions_check` here because that callback allows * anonymous access and the nonce verification inside the body is a * CSRF guard, not an authorization check. This callback gates the * endpoint on authentication + view capability so the permission * model is declared at route registration and does not depend on * defensive filtering inside the callback body. * * @psalm-suppress PossiblyUnusedParam -- REST framework passes $request. * @psalm-suppress PossiblyUnusedMethod -- Called by WP REST API callback. */ public function export_csv_permissions_check( WP_REST_Request $request ) : bool|\WP_Error { $rate_limit = $this->check_rate_limit(); if ( is_wp_error( $rate_limit ) ) { return $rate_limit; } if ( !is_user_logged_in() ) { return new WP_Error('rest_forbidden', __( 'You must be logged in to export calendar data.', 'agenticdaisy-calendar' ), [ 'status' => 401, ]); } if ( current_user_can( 'webcal_can_view_all_events' ) ) { return true; } return $this->check_permission( 'webcal_can_view' ); } /** * Check permissions for creating events. */ public function create_event_permissions_check( WP_REST_Request $request ) : bool|\WP_Error { $rate_limit = $this->check_rate_limit(); if ( is_wp_error( $rate_limit ) ) { return $rate_limit; } return $this->check_permission( 'webcal_can_edit' ); } /** * Get events in a date range. */ public function get_events( WP_REST_Request $request ) : WP_REST_Response|WP_Error { $start_date = $this->str( $request->get_param( 'start_date' ) ); $end_date = $this->str( $request->get_param( 'end_date' ) ); try { $range = new DateRange(new DateTimeImmutable($start_date), new DateTimeImmutable($end_date)); $event_service = $this->container->get_event_service(); $recurrence_svc = $this->container->get_recurrence_service(); $user_repo = $this->container->get_user_repository(); // Parse optional users filter $users_param = $this->str( $request->get_param( 'users' ) ); $users_filter = null; if ( !empty( $users_param ) ) { $users_filter = array_map( 'sanitize_text_field', explode( ',', $users_param ) ); $users_filter = array_filter( $users_filter ); // Resolve "current_user" placeholder to the logged-in user's login $cu_index = array_search( 'current_user', $users_filter, true ); if ( $cu_index !== false ) { if ( is_user_logged_in() ) { $users_filter[$cu_index] = wp_get_current_user()->user_login; } else { // Anonymous visitor: remove the placeholder entirely unset($users_filter[$cu_index]); } $users_filter = array_values( $users_filter ); } if ( empty( $users_filter ) ) { $users_filter = null; } } // Determine access context $user = null; $access_level = null; if ( !is_user_logged_in() ) { // Anonymous: public events only $access_level = 'P'; } elseif ( !current_user_can( 'webcal_can_view_all_events' ) ) { // Logged-in non-admin: layer-based visibility if ( $users_filter === null ) { // No shortcode filter — show own calendar + subscribed layers $current_login = wp_get_current_user()->user_login; $layer_service = $this->container->get_layer_service(); $layers = $layer_service->getLayersForUser( $current_login ); $visible_logins = [$current_login]; foreach ( $layers as $layer ) { $visible_logins[] = $layer->layerUser(); } $users_filter = array_unique( $visible_logins ); } // When shortcode provides explicit users, honour the list as-is. // The post-filter below ensures non-public events from other // users' calendars are hidden. } // Logged-in admin: $user and $access_level both null → no access filter // Global holidays are a shared overlay shown on every calendar. When an // explicit user filter is in effect it would otherwise exclude them, so // add the holidays creator (its events are Public and survive the // post-filter below). A null filter already returns them. if ( $users_filter !== null && !in_array( self::HOLIDAY_CREATOR, $users_filter, true ) ) { $users_filter[] = self::HOLIDAY_CREATOR; } $events_collection = $event_service->getEventsInDateRange( $range, $user, $access_level, $users_filter ); $events = iterator_to_array( $events_collection ); // Post-filter: remove non-Public events from other users' calendars. // Shared calendar (Resource) events are always visible when the // shortcode/block explicitly requests that calendar. if ( is_user_logged_in() && !current_user_can( 'webcal_can_view_all_events' ) ) { $current_login = wp_get_current_user()->user_login; // Build set of shared calendar logins from the users filter $shared_logins = []; if ( $users_filter !== null ) { $resource_repo = $this->container->get_resource_repository(); foreach ( $users_filter as $login ) { $resource = $resource_repo->findByLogin( $login ); if ( $resource !== null && $resource->url() === null ) { $shared_logins[$login] = true; } } } $events = array_values( array_filter( $events, function ( $event ) use($current_login, $shared_logins) { // Keep all own events regardless of access level if ( $event->createdBy() === $current_login ) { return true; } // Keep all events from explicitly-requested shared calendars if ( isset( $shared_logins[$event->createdBy()] ) ) { return true; } // Only show public events from other users' calendars return $event->access() === \WebCalendar\Core\Domain\ValueObject\AccessLevel::PUBLIC; } ) ); } // Post-filter: hide TENTATIVE events from public calendar. // Submitters can see their own pending events; admins see all. $events = array_values( array_filter( $events, function ( $event ) { if ( $event->status() !== 'TENTATIVE' ) { return true; } if ( is_user_logged_in() && $event->createdBy() === wp_get_current_user()->user_login ) { return true; } return current_user_can( 'webcal_can_approve' ); } ) ); // Server-side category filter: keep only events in the requested category. $category_param = $request->get_param( 'category' ); if ( !empty( $category_param ) && is_scalar( $category_param ) ) { $cat_id = $this->int( $category_param ); global $wpdb; // Match any event assigned to this category regardless of the // assignment's owner. cat_id uniquely identifies a category, so // joining on cat_owner would wrongly exclude personal assignments // (cat_owner = a user) of a global category (cat_owner = ''). $cat_event_ids = $wpdb->get_col( $wpdb->prepare( "SELECT ec.cal_id\n\t\t\t\t\t\t FROM {$wpdb->prefix}webcal_entry_categories ec\n\t\t\t\t\t\t WHERE ec.cat_id = %d", $cat_id ) ); $cat_event_set = array_flip( $cat_event_ids ); $events = array_values( array_filter( $events, fn( $event ) => isset( $cat_event_set[$event->id()->value()] ) ) ); } // Batch-load participants and categories for all events (avoids N+1 queries) $event_ids = array_map( fn( $e ) => $e->id()->value(), $events ); $participant_repo = $this->container->get_participant_repository(); $participants_map = $participant_repo->get_participants_batch( $event_ids ); $category_repo = $this->container->get_category_repository(); // Category assignments are stored per assigning user // (webcal_entry_categories.cat_owner), so an event's categories belong // to its creator. Read each event's categories as its creator — not the // viewer — otherwise events shown to a different user (admin, anonymous, // or a migrated calendar) appear uncategorised. Group by creator because // getForEventsBatch() filters on a single owner. $ids_by_creator = array(); foreach ( $events as $creator_event ) { $ids_by_creator[$creator_event->createdBy()][] = $creator_event->id()->value(); } $categories_map = array(); foreach ( $ids_by_creator as $creator => $creator_ids ) { $categories_map += $this->transformer()->get_categories_batch( $creator_ids, (string) $creator ); } $attachments_map = $this->transformer()->get_attachments_batch( $event_ids ); $featured_images_map = $this->transformer()->get_featured_images_batch( $event_ids ); // Lightweight queue of refs keeps peak memory proportional to base // events + occurrences, not the fully-transformed response. /** @var array $queue */ $queue = []; foreach ( $events as $event ) { try { if ( $event->recurrence()->isRepeating() ) { $occurrences = $recurrence_svc->expand( $event, $range ); foreach ( $occurrences as $occurrence ) { $queue[] = [ 'event' => $event, 'occurrence' => $occurrence, ]; } } else { $queue[] = [ 'event' => $event, 'occurrence' => null, ]; } } catch ( \Throwable $e ) { // One event with an unparseable recurrence rule (e.g. a corrupt // rule left by a migration) must not blank the whole calendar. // Skip it and log; the rest of the feed still renders. $this->get_logger()->error( sprintf( 'Skipped event %d with an invalid recurrence rule: %s', $event->id()->value(), $e->getMessage() ) ); } } // Pagination applied to the lightweight queue. $total = count( $queue ); $per_page = $this->int( $request->get_param( 'per_page' ) ?? 500 ); $per_page = min( max( $per_page, 1 ), 1000 ); $page = max( $this->int( $request->get_param( 'page' ) ?? 1 ), 1 ); $pages = (int) ceil( $total / $per_page ); $offset = ($page - 1) * $per_page; $slice = array_slice( $queue, $offset, $per_page ); // Transform only the paginated slice. $data = []; foreach ( $slice as $item ) { $eid = $item['event']->id()->value(); $preloaded = [ 'participants' => $participants_map[$eid] ?? [], 'category' => $categories_map[$eid] ?? [ 'id' => null, 'color' => null, ], 'attachments' => $attachments_map[$eid] ?? [], 'featured_image_id' => $featured_images_map[$eid] ?? 0, ]; if ( $item['occurrence'] instanceof \WebCalendarDeps\Icalendar\Recurrence\Occurrence ) { $data[] = $this->transformer()->transform_occurrence( $item['occurrence'], $item['event'], $preloaded ); } else { $data[] = $this->transformer()->transform_event( $item['event'], $preloaded ); } } /** @var array> $data */ $data = apply_filters( 'webcal_rest_events_data', $data, $range ); // Sparse fieldsets: ?_fields=id,title,start reduces payload size. $fields_param = $this->str( $request->get_param( '_fields' ) ); if ( !empty( $fields_param ) ) { $fields = array_map( 'trim', explode( ',', $fields_param ) ); $data = array_map( function ( array $item ) use($fields) : array { return array_intersect_key( $item, array_flip( $fields ) ); }, $data ); } $response = new WP_REST_Response($data, 200); $response->header( 'X-WP-Total', (string) $total ); $response->header( 'X-WP-TotalPages', (string) $pages ); return $response; } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in EventsController (get_events): ' . $e->getMessage() ); return new WP_Error('webcal_error', $e->getMessage(), [ 'status' => 500, ]); } } /** * Create an event. */ public function create_event( WP_REST_Request $request ) : WP_REST_Response|WP_Error { /** @var array $params */ $params = $this->safe_params( $request ); /** @var array $params */ $params = apply_filters( 'webcal_pre_create_event', $params ); try { $event_service = $this->container->get_event_service(); // Handle instance edit by adding EXDATE to the original event if ( !empty( $params['isInstanceEdit'] ) && !empty( $params['originalEventId'] ) && !empty( $params['instanceDate'] ) ) { $original = $event_service->getEventById( new \WebCalendar\Core\Domain\ValueObject\EventId($this->int( $params['originalEventId'] )) ); if ( $original ) { $ex_date = $original->recurrence()->exDate(); $new_ex_dates = $ex_date->dates(); $new_ex_dates[] = new \DateTimeImmutable($this->str( $params['instanceDate'] )); $new_recurrence = new \WebCalendar\Core\Domain\ValueObject\Recurrence($original->recurrence()->rule(), new \WebCalendar\Core\Domain\ValueObject\ExDate($new_ex_dates), $original->recurrence()->rDate()); $updated_original = new \WebCalendar\Core\Domain\Entity\Event( id: $original->id(), uid: $original->uid(), name: $original->name(), description: $original->description(), location: $original->location(), start: $original->start(), duration: $original->duration(), createdBy: $original->createdBy(), type: $original->type(), access: $original->access(), recurrence: $new_recurrence ); $event_service->updateEvent( $updated_original, $this->get_current_actor() ); } } $recurrence = new \WebCalendar\Core\Domain\ValueObject\Recurrence(); if ( !empty( $params['rrule'] ) ) { $recurrence = new \WebCalendar\Core\Domain\ValueObject\Recurrence(new \WebCalendar\Core\Domain\ValueObject\RecurrenceRule($this->str( $params['rrule'] ))); } $is_all_day = !empty( $params['allDay'] ); $creator_login = wp_get_current_user()->user_login; // Resolve access level: explicit param > user preference > 'P' $access_str = $this->str( $params['access'] ?? '' ); if ( $access_str === '' ) { $user_repo = $this->container->get_user_repository(); $prefs = $user_repo->getPreferences( $creator_login ); foreach ( $prefs as $pref ) { if ( $pref->key() === 'default_access' ) { $access_str = $pref->value(); break; } } if ( $access_str === '' ) { $access_str = 'P'; } } $event = new \WebCalendar\Core\Domain\Entity\Event( id: new \WebCalendar\Core\Domain\ValueObject\EventId(0), uid: wp_generate_uuid4(), // Values below come from the route's args schema (see // event_write_args(), WPORG-7.3) — WP has already // sanitized/validated them at has_valid_params() / // sanitize_params() time. The $this->str()/$this->int() // wrappers are kept as the PHPStan-Level-9-compliant // mixed→narrow coercion; the redundant sanitize_text_field() // and wp_kses_post() outer wrappers are gone. name: $this->str( $params['title'] ?? '' ), description: $this->str( $params['description'] ?? '' ), location: $this->str( $params['location'] ?? '' ), start: new \DateTimeImmutable($this->str( $params['start'] )), duration: ( $is_all_day ? 1440 : $this->int( $params['duration'] ?? 60 ) ), createdBy: $creator_login, type: \WebCalendar\Core\Domain\ValueObject\EventType::EVENT, access: $this->action_service()->parse_access_level( $access_str ), recurrence: $recurrence, allDay: $is_all_day ); // Conflict detection (Starter+ only). The whole premium block — // including the tier check — lives inside can_use_premium_code so it // is stripped from the free build; no license check ships to wp.org. if ( empty( $params['force'] ) ) { } $event_service->createEvent( $event, $this->get_current_actor() ); // The entity is readonly so id stays 0 after save. // Look up the created event by UID to get the real ID. $event_repo = $this->container->get_event_repository(); $saved_event = $event_repo->findByUid( $event->uid() ); $event_id = ( $saved_event ? $saved_event->id()->value() : 0 ); if ( $event_id ) { $this->audit_log( $event_id, wp_get_current_user()->user_login, \WebCalendar\Core\Domain\ValueObject\ActivityLogType::CREATE, $event->name() ); do_action( 'webcal_event_created', $event_id, $event, wp_get_current_user()->user_login ); } // Handle category association if ( $event_id && !empty( $params['category_id'] ) ) { $this->action_service()->save_category( $event_id, $this->int( $params['category_id'] ) ); } // Handle participants — always include the creator if ( $event_id ) { $participant_repo = $this->container->get_participant_repository(); $participant_param = ( !empty( $params['participants'] ) ? $params['participants'] : [] ); /** @var string[] $participant_list */ $participant_list = ( is_array( $participant_param ) ? array_map( fn( $v ) => (string) (( is_scalar( $v ) ? $v : '' )), $participant_param ) : [] ); if ( !in_array( $creator_login, $participant_list, true ) ) { $participant_list[] = $creator_login; } $participant_repo->save_participants( $event_id, $participant_list ); // Notify other participants (not the creator) $others = array_filter( $participant_list, fn( $p ) => $p !== $creator_login ); if ( !empty( $others ) ) { /** @phpstan-ignore argument.type (type narrowing differs across PHPStan versions) */ $this->action_service()->notify_participants( $saved_event, array_values( $others ) ); } } // Handle attachments (stored as WP option keyed by event ID) if ( $event_id && !empty( $params['attachments'] ) ) { update_option( 'webcal_attachments_' . $event_id, $params['attachments'] ); } // Handle featured image (stored as WP option keyed by event ID) if ( $event_id && !empty( $params['featured_image_id'] ) ) { update_option( 'webcal_featured_image_' . $event_id, $this->int( $params['featured_image_id'] ), false ); } return new WP_REST_Response([ 'id' => $event_id, ], 201); } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in EventsController (create_event): ' . $e->getMessage() ); return new WP_Error('webcal_error', $e->getMessage(), [ 'status' => 500, ]); } } }