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). */ /** * A conflicting event, reported without disclosing what it is. * * Every other serialising path answers "may this viewer see it?" by * dropping what fails, through EventVisibility::filter(). That is wrong * here, and wrongly *safe*: a conflict the viewer may not read would be * dropped, no clash would be reported, and the double-booking it exists to * prevent would go through. The clash has to be counted while its details * stay hidden — redaction, not omission — which is why this path was not * fixed alongside the other four in 1.2.0. * * What survives is the time window. That is the one disclosure conflict * detection cannot avoid making: telling you a participant is unavailable * is the entire point of asking. It is the same trade RFC 5545 free/busy * makes, and the meaning CONFIDENTIAL has carried since legacy WebCalendar * — time visible, details not. * * @return array * * @psalm-suppress UnusedMethod -- Called from both conflict branches * through an arrow function; Psalm misses self-calls made inside one. The * calls are real: reverting them to transform_event() turns * EventExposureTest's conflict cases red. */ private function conflict_payload( \WebCalendar\Core\Domain\Entity\Event $event ) : array { if ( EventVisibility::can_view( $event ) ) { return $this->transformer()->transform_event( $event ); } // Deliberately no id: the caller has no use for one here, and handing // out the id of an event it may not read invites a follow-up fetch. $date_format = ( $event->isAllDay() ? 'Y-m-d' : 'Y-m-d\\TH:i:s' ); return [ 'title' => __( 'Busy', 'agenticdaisy-calendar' ), 'start' => $event->start()->format( $date_format ), 'end' => ( $event->isAllDay() ? $event->start()->format( $date_format ) : $event->end()->format( $date_format ) ), 'duration' => $event->duration(), 'redacted' => true, ]; } 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 ), ]; $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 ), 'tags' => $this->rest_arg_tag_names(), 'venue_id' => $this->rest_arg_positive_int( false ), 'organizer_id' => $this->rest_arg_positive_int( false ), 'extras' => [ 'required' => false, 'type' => 'object', 'sanitize_callback' => static function ( $value ) : array { if ( !is_array( $value ) ) { return []; } $clean = []; foreach ( $value as $name => $raw ) { if ( is_string( $name ) && is_scalar( $raw ) ) { $clean[$name] = (string) $raw; } } return $clean; }, ], 'conference_url' => $this->rest_arg_url( false, true ), 'conference_label' => $this->rest_arg_string( 60, false ), 'participants' => $array_of_logins, 'force' => $this->rest_arg_bool( false ), 'attachments' => $this->rest_arg_attachments(), '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' => 'sanitize_text_field', ], 'search' => [ 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ], 'tags' => [ 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ], 'venue' => [ 'required' => false, 'sanitize_callback' => 'absint', ], 'organizer' => [ '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, 'export_permissions_check'], ]] ); register_rest_route( $this->namespace, '/events/export/csv', [[ 'methods' => 'GET', 'callback' => [$this, 'export_events_csv'], 'permission_callback' => [$this, 'export_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' => array_merge( $this->event_id_arg(), // Start of one occurrence of a repeating event: // remove just that one and keep the series. [ 'occurrence' => $this->rest_arg_iso_date( false ), ] ), ]] ); register_rest_route( $this->namespace, '/events/(?P\\d+)/duplicate', [[ 'methods' => 'POST', 'callback' => [$this, 'duplicate_event'], 'permission_callback' => [$this, 'duplicate_event_permissions_check'], 'args' => array_merge( $this->event_id_arg(), [ 'title' => [ 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ], ] ), ]] ); 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; } return $this->can_read_event( $this->int( $request->get_param( 'id' ) ) ); } /** * Read authorization for one event, without the rate-limit check. * * Split out of get_event_permissions_check() so the duplicate callback * can reuse it: RateLimiter::is_exceeded() counts as well as checks, so * calling both public callbacks would spend two slots on one request. * * @param int $id Event ID. */ private function can_read_event( int $id ) : bool|\WP_Error { $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 } // A pending submission is Public by construction, so the moderation // rule has to come before both shortcuts below or it never applies. if ( 'TENTATIVE' === $event->status() && !\WebCalendar\WP\Security\EventVisibility::can_view( $event ) ) { return new \WP_Error('rest_forbidden', __( 'You do not have permission to view this event.', 'agenticdaisy-calendar' ), [ 'status' => 403, ]); } if ( current_user_can( 'webcal_can_view_all_events' ) ) { return true; } // 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() ); // Only the editable fields are named; with() carries the rest. // That matters because save() is a full-row UPDATE, so a field // left out of a rebuilt entity is not "left alone" — it is // written back as NULL. Listing fields by hand once dropped an // event's venue, organizer and conference details on every edit // (a TEC-imported event lost its venue the first time anyone // renamed it) and cleared the TENTATIVE status that holds a // frontend submission back, publishing it without an approval. // // 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. $venue_id = $this->resolve_venue_id( $params['venue_id'] ?? null, $existing->venueId() ); if ( is_wp_error( $venue_id ) ) { return $venue_id; } $organizer_id = $this->resolve_organizer_id( $params['organizer_id'] ?? null, $existing->organizerId() ); if ( is_wp_error( $organizer_id ) ) { return $organizer_id; } $event = $existing->with( id: new \WebCalendar\Core\Domain\ValueObject\EventId($id), 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() ) ), access: ( isset( $params['access'] ) ? $this->action_service()->parse_access_level( $this->str( $params['access'] ) ) : $existing->access() ), recurrence: $recurrence, allDay: $is_all_day, // An empty string clears the link, an absent key leaves it. conferenceUrl: $this->nullable_str( $params['conference_url'] ?? null, $existing->conferenceUrl() ), conferenceLabel: $this->nullable_str( $params['conference_label'] ?? null, $existing->conferenceLabel() ), venueId: $venue_id, organizerId: $organizer_id ); // 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 ); } } // Tags. Absent means "leave them alone" (this route accepts // partial updates); an empty array means "clear them". Both // save_* calls preserve the other kind, so order does not matter. if ( isset( $params['tags'] ) ) { $this->action_service()->save_tags( $id, $this->resolve_tag_ids( $params['tags'] ) ); } // Custom fields, same rule as tags: absent leaves them alone, an // empty object clears them. saveForEvent() replaces the whole set // for the event, which is what "clear" needs. if ( isset( $params['extras'] ) && is_array( $params['extras'] ) ) { /** @var array $submitted_extras */ $submitted_extras = $params['extras']; $this->container->get_site_extra_service()->saveExtrasForEvent( $id, \WebCalendar\WP\SiteExtraFields::filter_values( $submitted_extras ) ); } // 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 ) ) { // Inclusive end day — see range_end(). $range = new DateRange(new DateTimeImmutable($start_date), $this->range_end( $end_date )); } else { $now = new \DateTimeImmutable(); $range = new DateRange($now->modify( '-1 year' ), $now->modify( '+1 year' )); } // Scope to what this viewer may see. Without this the export is a // full-calendar dump: an administrative scope applies no access // filter at all. $events = EventVisibility::filter( $event_service->getEventsInDateRange( $range, EventVisibility::scope_for_viewer() ) ); // 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 = []; $tag_map = []; foreach ( $events as $event ) { $event_id = $event->id()->value(); // getForEvent() returns categories and tags together; the // export keeps them apart so X-WEBCAL-TAGS can say which // of the exported names were tags. $split = \WebCalendar\WP\EventLabels::partition( $category_repo->getForEvent( $event->id(), $event->createdBy() ) ); if ( array() !== $split['categories'] ) { $category_map[$event_id] = $split['categories']; } if ( array() !== $split['tags'] ) { $tag_map[$event_id] = $split['tags']; } } $ics = $export_svc->exportIcal( new EventCollection($events), $category_map, $tag_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 ( \Throwable $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 ) ) { // Inclusive end day — see range_end(). $range = new DateRange(new DateTimeImmutable($start_date), $this->range_end( $end_date )); } else { $now = new \DateTimeImmutable(); $range = new DateRange($now->modify( '-1 year' ), $now->modify( '+1 year' )); } // Scope to what this viewer may see. webcal_can_view reaches as far // as Subscriber, so authentication alone does not entitle the // caller to other people's Private and Confidential events. $events = EventVisibility::filter( $event_service->getEventsInDateRange( $range, EventVisibility::scope_for_viewer() ) ); // 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(), $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 ]; $this->put_csv_row( $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 ( \Throwable $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 the label maps for this event. Labels belong to the event's // creator (see get_events()), so read them as the creator, not the // viewer; categories and tags are exported apart so the tags can be // identified as such on the way back in. $category_map = []; $tag_map = []; $split = \WebCalendar\WP\EventLabels::partition( $category_repo->getForEvent( $event->id(), $event->createdBy() ) ); if ( array() !== $split['categories'] ) { $category_map[$id] = $split['categories']; } if ( array() !== $split['tags'] ) { $tag_map[$id] = $split['tags']; } $ics = $export_svc->exportIcal( $collection, $category_map, $tag_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 ( \Throwable $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. */ /** * Resolve an optional nullable string parameter for a partial update. * * Distinguishes three cases the REST routes rely on: the key is absent * (keep `$current`), it is present and blank (clear the field), or it * carries a value (use it). * * @param mixed $raw Raw parameter value, or null when absent. * @param ?string $current Value to keep when the parameter is absent. */ private function nullable_str( $raw, ?string $current ) : ?string { if ( null === $raw ) { return $current; } $value = ( is_scalar( $raw ) ? trim( (string) $raw ) : '' ); return ( '' === $value ? null : $value ); } /** * Resolve the venue_id parameter to a VenueId, or null for "no venue". * * Three cases, and they have to stay distinct: the key is absent (leave * $current alone — a partial update must not detach a venue it never * mentioned), the key is 0 or empty (detach), or the key names a venue * (use it, once it is known to exist). * * @param mixed $raw Raw request parameter. * @return \WebCalendar\Core\Domain\ValueObject\VenueId|null|WP_Error * * @psalm-suppress UnusedMethod -- Called from create_event and update_event. */ private function resolve_venue_id( $raw, ?\WebCalendar\Core\Domain\ValueObject\VenueId $current ) { if ( null === $raw ) { return $current; } $id = $this->int( $raw ); if ( $id <= 0 ) { return null; } $venue_id = new \WebCalendar\Core\Domain\ValueObject\VenueId($id); if ( null === $this->container->get_venue_repository()->findById( $venue_id ) ) { return new WP_Error('rest_invalid_param', __( 'No venue with that id exists.', 'agenticdaisy-calendar' ), [ 'status' => 400, ]); } return $venue_id; } /** * Resolve the organizer_id parameter. Same three cases as the venue. * * @param mixed $raw Raw request parameter. * @return \WebCalendar\Core\Domain\ValueObject\OrganizerId|null|WP_Error * * @psalm-suppress UnusedMethod -- Called from create_event and update_event. */ private function resolve_organizer_id( $raw, ?\WebCalendar\Core\Domain\ValueObject\OrganizerId $current ) { if ( null === $raw ) { return $current; } $id = $this->int( $raw ); if ( $id <= 0 ) { return null; } $organizer_id = new \WebCalendar\Core\Domain\ValueObject\OrganizerId($id); if ( null === $this->container->get_organizer_repository()->findById( $organizer_id ) ) { return new WP_Error('rest_invalid_param', __( 'No organizer with that id exists.', 'agenticdaisy-calendar' ), [ 'status' => 400, ]); } return $organizer_id; } /** * Turn submitted tag names into tag ids, creating any that are new. * * Tags are global and flat, so users type them rather than pick them. * Matching is case-insensitive in core, so re-submitting "Outdoors" * against an existing "outdoors" reuses the row instead of creating a * near-duplicate. * * @param mixed $raw Raw `tags` parameter (already sanitized to a name list). * @return int[] Tag category ids, in submission order. */ private function resolve_tag_ids( $raw ) : array { if ( !is_array( $raw ) || [] === $raw ) { return []; } $category_service = $this->container->get_category_service(); $actor = $this->get_current_actor(); $ids = []; foreach ( $raw as $name ) { if ( !is_string( $name ) || '' === $name ) { continue; } $ids[] = $category_service->matchOrCreateTag( $name, $actor )->id(); } return array_values( array_unique( $ids ) ); } /** * Duplication is a create, so the create capability is the floor. * * can_read_event() alone is not enough — it deliberately lets anonymous * visitors read public events, and copying one is not a read. */ public function duplicate_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; } $can_create = $this->check_permission( 'webcal_can_edit' ); if ( is_wp_error( $can_create ) ) { return $can_create; } return $this->can_read_event( $this->int( $request->get_param( 'id' ) ) ); } /** * Duplicate an event, or a whole recurring series, in one call. * * The deep copy — recurrence, participants with their statuses, * categories, the reminder with its sent-state reset, and the venue and * organizer references — is core's EventDuplicationService. What stays * here is the app-side data core has no view of: attachments and the * featured image, both WordPress options keyed by event id. * * Participants are deliberately NOT notified. Duplicating is an * authoring step, usually followed by editing the copy; mailing every * participant of the original at that point would be surprising. */ public function duplicate_event( WP_REST_Request $request ) : WP_REST_Response|WP_Error { $id = $this->int( $request->get_param( 'id' ) ); $raw_title = $request->get_param( 'title' ); $title = ( is_string( $raw_title ) ? trim( $raw_title ) : '' ); try { $copy = $this->container->get_event_duplication_service()->duplicate( new \WebCalendar\Core\Domain\ValueObject\EventId($id), wp_generate_uuid4(), $this->get_current_actor(), ( '' !== $title ? $title : null ) ); $new_id = $copy->id()->value(); $attachments = get_option( 'webcal_attachments_' . $id ); if ( !empty( $attachments ) ) { update_option( 'webcal_attachments_' . $new_id, $attachments ); } $featured = get_option( 'webcal_featured_image_' . $id ); if ( !empty( $featured ) && is_numeric( $featured ) ) { update_option( 'webcal_featured_image_' . $new_id, (int) $featured, false ); } do_action( 'webcal_event_created', $new_id, $copy, wp_get_current_user()->user_login ); return new WP_REST_Response([ 'id' => $new_id, ], 201); } catch ( \DomainException $e ) { return new WP_Error('webcal_not_found', $e->getMessage(), [ 'status' => 404, ]); } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in EventsController (duplicate_event): ' . esc_html( $e->getMessage() ) ); return new WP_Error('webcal_error', $e->getMessage(), [ 'status' => 500, ]); } } public function delete_event( WP_REST_Request $request ) : WP_REST_Response|WP_Error { $id = $this->int( $request->get_param( 'id' ) ); $occurrence = $this->str( $request->get_param( 'occurrence' ) ); if ( $occurrence !== '' ) { return $this->delete_occurrence( $id, $occurrence ); } 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 ); // Custom fields, attachments, RSVP rows and the rest are keyed to // an id the next new event will be handed; see EventSideData. \WebCalendar\WP\Database\EventSideData::purge( $id ); return new WP_REST_Response(null, 204); } catch ( \DomainException $e ) { return $this->delete_refused( $e ); } 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, ]); } } /** * Remove one occurrence of a repeating event, keeping the rest of the series. * * Nothing is deleted: the date becomes an EXDATE on the series, the same * way "edit only this occurrence" takes the original out of the way. The * series' attachments, featured image and custom fields stay with it. * * @psalm-suppress UnusedMethod -- Called from delete_event(), which Psalm * only sees as a route callback. */ private function delete_occurrence( int $id, string $occurrence ) : WP_REST_Response|WP_Error { try { $event_service = $this->container->get_event_service(); $series = $event_service->getEventById( new \WebCalendar\Core\Domain\ValueObject\EventId($id) ); if ( !$series ) { return new WP_Error('webcal_not_found', __( 'Event not found.', 'agenticdaisy-calendar' ), [ 'status' => 404, ]); } if ( !$series->recurrence()->isRepeating() ) { return new WP_Error('webcal_not_repeating', __( 'Only a repeating event has occurrences to delete.', 'agenticdaisy-calendar' ), [ 'status' => 400, ]); } $updated = $this->exclude_occurrence( $series, $occurrence ); $this->audit_log( $id, wp_get_current_user()->user_login, \WebCalendar\Core\Domain\ValueObject\ActivityLogType::UPDATE, $series->name() ); do_action( 'webcal_event_updated', $id, $updated, wp_get_current_user()->user_login ); return new WP_REST_Response(null, 204); } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in EventsController (delete_occurrence): ' . $e->getMessage() ); return new WP_Error('webcal_error', $e->getMessage(), [ 'status' => 500, ]); } } /** * Add an EXDATE for one occurrence to a repeating event and save it. * * Only the recurrence changes. with() carries everything else; save() is * a full-row UPDATE, so a field left out of a rebuild is written back as * NULL, which once turned an all-day series into a timed one and wiped * the series' venue, organizer and conference details. * * @psalm-suppress UnusedMethod -- Called from create_event() and * delete_occurrence(); see delete_occurrence(). */ private function exclude_occurrence( \WebCalendar\Core\Domain\Entity\Event $series, string $occurrence ) : \WebCalendar\Core\Domain\Entity\Event { $recurrence = $series->recurrence(); $ex_dates = $recurrence->exDate()->dates(); $ex_dates[] = new \DateTimeImmutable($occurrence); $new_recurrence = new \WebCalendar\Core\Domain\ValueObject\Recurrence($recurrence->rule(), new \WebCalendar\Core\Domain\ValueObject\ExDate($ex_dates), $recurrence->rDate()); $updated = $series->with( recurrence: $new_recurrence ); $this->container->get_event_service()->updateEvent( $updated, $this->get_current_actor() ); return $updated; } /** * 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 both bulk export routes (ICS and CSV). * * Bulk export is an admin-grade operation; unlike the ICS subscription * feed (which is a legitimate public-read endpoint, token-scoped in * FeedController), an export dumps the whole calendar in one request and * has no public-consumption use case. The WP.org review team flagged * re-using `get_events_permissions_check` for CSV because that callback * allows anonymous access and the nonce verification inside the body is a * CSRF guard, not an authorization check. * * The ICS route kept that callback and shipped with the same hole, which * was worse than it looked: the `wp_rest` nonce the handler verifies is * the *anonymous* nonce when there is no session, and it is printed into * public page HTML by the shortcode and the mini-calendar widget. Any * visitor could read it off a calendar page and download every Private * and Confidential event. Both routes now declare the permission model at * registration, and both handlers additionally scope their results * through EventVisibility so authentication alone is not the whole * defence — webcal_can_view reaches as far as Subscriber. * * @psalm-suppress PossiblyUnusedParam -- REST framework passes $request. * @psalm-suppress PossiblyUnusedMethod -- Called by WP REST API callback. */ public function export_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' ); } /** * Narrow an already permission-scoped event list by the Filter Bar facets. * * The filtering runs through core's SearchCriteria rather than SQL here, * but it deliberately does *not* hand core the calendar's date range: * `findByDateRange()` includes recurring events whose own start precedes * the window, while the criteria's range filter is a plain date * comparison, so passing the range would drop every recurring event as * soon as any filter was applied. Instead the resolved event ids go in as * `eventIds`, which can only ever remove rows — the result cannot contain * an event this viewer was not already allowed to see. * * Facets AND together; values within a facet OR. Categories and tags * share one criteria field with any-of semantics, so a request using both * needs a second pass to keep them ANDed. * * @param list<\WebCalendar\Core\Domain\Entity\Event> $events Access-filtered events. * @return list<\WebCalendar\Core\Domain\Entity\Event> * * @psalm-suppress UnusedMethod -- Called from get_events; Psalm misses some self-calls. */ private function apply_filter_bar( WP_REST_Request $request, array $events ) : array { $keyword = trim( $this->str( $request->get_param( 'search' ) ) ); $tag_ids = $this->id_list( $request->get_param( 'tags' ) ); $category = $request->get_param( 'category' ); $category_ids = $this->id_list( $category ); // Asked for categories but named none that exist: that is a filter // matching nothing, not the absence of a filter. Read the other way, // a shortcode naming a missing category showed the whole calendar. $category_given = is_scalar( $category ) && '' !== trim( (string) $category ); if ( $category_given && [] === $category_ids ) { return []; } // Venue and organizer filtering is a Starter+ feature. On the free // tier the controls are not rendered and the parameters are ignored, // which can only widen the result — never leak a gated event. $venue_ids = []; $organizer_ids = []; if ( \webcal_feature_available( 'filter_by_place' ) ) { $venue = $request->get_param( 'venue' ); $organizer = $request->get_param( 'organizer' ); if ( !empty( $venue ) && is_scalar( $venue ) ) { $venue_ids[] = $this->int( $venue ); } if ( !empty( $organizer ) && is_scalar( $organizer ) ) { $organizer_ids[] = $this->int( $organizer ); } } $has_filter = '' !== $keyword || [] !== $category_ids || [] !== $tag_ids || [] !== $venue_ids || [] !== $organizer_ids; if ( !$has_filter ) { return $events; } // An empty id set must never reach core: SearchCriteria reads an empty // eventIds as "no restriction", so a query here would return the whole // table to a viewer entitled to none of it. Chunking below happens to // make that unreachable today (no ids, no chunks, no query), but that // is a side effect of how the loop is written rather than a decision, // and the cost of it silently changing is a data leak. if ( [] === $events ) { return []; } $allowed = array_map( static fn( \WebCalendar\Core\Domain\Entity\Event $e ): int => $e->id()->value(), $events ); // Everything except tags ANDs naturally inside one criteria. Tags // need a pass of their own, because they would otherwise join the // categories any-of list and turn an intersection into a union. if ( '' !== $keyword || [] !== $category_ids || [] !== $venue_ids || [] !== $organizer_ids ) { $allowed = $this->matching_ids( $allowed, $keyword, $category_ids, $venue_ids, $organizer_ids ); } if ( [] !== $tag_ids && [] !== $allowed ) { $allowed = $this->matching_ids( $allowed, '', $tag_ids, [], [] ); } $keep = array_flip( $allowed ); return array_values( array_filter( $events, static fn( \WebCalendar\Core\Domain\Entity\Event $e ): bool => isset( $keep[$e->id()->value()] ) ) ); } /** * Which of these event ids match the given facets. * * Chunked to the criteria's page size so a large calendar window cannot * silently drop matches past the limit. * * @param list $ids Candidate ids; never empty. * @param list $category_ids Category or tag ids, any-of. * @param list $venue_ids Venue ids, any-of. * @param list $organizer_ids Organizer ids, any-of. * @return list * * @psalm-suppress UnusedMethod -- Called from apply_filter_bar; Psalm misses some self-calls. */ private function matching_ids( array $ids, string $keyword, array $category_ids, array $venue_ids, array $organizer_ids ) : array { $search = $this->container->get_search_service(); $matched = []; $chunk_size = \WebCalendar\Core\Domain\ValueObject\SearchCriteria::MAX_LIMIT; foreach ( array_chunk( $ids, $chunk_size ) as $chunk ) { $criteria = new \WebCalendar\Core\Domain\ValueObject\SearchCriteria( keyword: ( '' === $keyword ? null : $keyword ), categoryIds: $category_ids, venueIds: $venue_ids, organizerIds: $organizer_ids, limit: $chunk_size, eventIds: $chunk ); // Administrative: this only ever narrows $ids, which the caller // already ran through EventVisibility::filter(), so the scope // cannot widen what comes back. Scoping again here would drop a // viewer's own Private events from their own filtered calendar. $scope = \WebCalendar\Core\Domain\ValueObject\EventScope::administrative(); foreach ( $search->searchByCriteria( $criteria, $scope ) as $event ) { $matched[] = $event->id()->value(); } } return $matched; } /** * A comma-separated list of positive integer ids from a request param. * * @return list * * @psalm-suppress UnusedMethod -- Called from apply_filter_bar; Psalm misses some self-calls. */ private function id_list( mixed $raw ) : array { if ( is_array( $raw ) ) { $parts = $raw; } elseif ( is_scalar( $raw ) ) { $parts = explode( ',', (string) $raw ); } else { return []; } $ids = []; foreach ( $parts as $part ) { if ( !is_scalar( $part ) ) { continue; } $id = (int) trim( (string) $part ); if ( $id > 0 && !in_array( $id, $ids, true ) ) { $ids[] = $id; } } return $ids; } /** * The inclusive end instant for an end_date request parameter. * * end_date has always been an inclusive calendar day for non-repeating * events (integer-date SQL BETWEEN), but a bare date parses to midnight, * which clipped recurring occurrences later on the final requested day * (found by the TEC-import E2E: an 18:00 occurrence on a one-day window * came back empty). Bare Ymd / Y-m-d dates therefore mean the whole day; * explicit datetimes are honored unchanged. * * @psalm-suppress UnusedMethod -- Called from get_events and the export endpoints; Psalm misses some self-calls. */ private function range_end( string $end_date ) : DateTimeImmutable { if ( preg_match( '/^\\d{8}$/', $end_date ) || preg_match( '/^\\d{4}-\\d{2}-\\d{2}$/', $end_date ) ) { return new DateTimeImmutable($end_date . ' 23:59:59'); } return new DateTimeImmutable($end_date); } /** * 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), $this->range_end( $end_date )); // Expansion cost is set by the window, which the caller picks; // see ExpansionWindow. if ( !ExpansionWindow::allows( $range->startDate(), $range->endDate() ) ) { return new WP_Error('webcal_range_too_large', __( 'That date range is too large.', 'agenticdaisy-calendar' ), [ 'status' => 400, ]); } $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. What the viewer may see is // EventVisibility's to state, not this endpoint's — that it was // stated here is exactly why four other endpoints shipped without // it — so all that is decided here is which calendars to read. if ( is_user_logged_in() && !current_user_can( 'webcal_can_view_all_events' ) && $users_filter === null ) { // Logged-in non-admin with no shortcode filter: own calendar // plus 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 the shortcode provides explicit users, honour the list // as-is. The post-filter below hides non-public events from other // users' calendars. // 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, EventVisibility::scope_for_viewer()->limitedToUsers( $users_filter ?? [] ) ); $events = iterator_to_array( $events_collection ); // Shared calendar (Resource) events stay visible when the // shortcode/block explicitly requests that calendar, so resolve // which of the requested logins are shared calendars. Only worth // the lookups for a viewer the filter can actually narrow. $shared_logins = []; if ( $users_filter !== null && is_user_logged_in() && !current_user_can( 'webcal_can_view_all_events' ) ) { $resource_repo = $this->container->get_resource_repository(); foreach ( $users_filter as $login ) { $resource = $resource_repo->findByLogin( $login ); if ( $resource !== null && EventVisibility::entitled_to_shared_calendar( $resource ) ) { $shared_logins[$login] = true; } } } // Drop non-Public events from other people's calendars, and // pending submissions from everyone but their submitter and the // moderators. This rule used to be written out here, which is // exactly why four other endpoints went without it; it now lives // in EventVisibility and every serialising path shares it. $events = EventVisibility::filter( $events, $shared_logins ); // Filter Bar (Epic 25): narrow the events this viewer may see by // the requested facets. Runs before recurrence expansion so a // filtered series keeps all of its occurrences. $events = $this->apply_filter_bar( $request, $events ); // 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(); $tags_map = array(); foreach ( $ids_by_creator as $creator => $creator_ids ) { $categories_map += $this->transformer()->get_categories_batch( $creator_ids, (string) $creator ); // Tags live in the same junction table and are owner-scoped // the same way, so they need the same per-creator grouping. $tags_map += $this->transformer()->get_tags_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, 'tags' => $tags_map[$eid] ?? [], ]; 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(); // An "only this occurrence" edit: the series it replaces gets an // EXDATE, but only once the replacement exists (below). Done // first, a failed save would lose the occurrence altogether. $replaces = null; if ( !empty( $params['isInstanceEdit'] ) && !empty( $params['originalEventId'] ) && !empty( $params['instanceDate'] ) ) { $replaces = $event_service->getEventById( new \WebCalendar\Core\Domain\ValueObject\EventId($this->int( $params['originalEventId'] )) ); } $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'; } } $venue_id = $this->resolve_venue_id( $params['venue_id'] ?? null, null ); if ( is_wp_error( $venue_id ) ) { return $venue_id; } $organizer_id = $this->resolve_organizer_id( $params['organizer_id'] ?? null, null ); if ( is_wp_error( $organizer_id ) ) { return $organizer_id; } $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, conferenceUrl: $this->nullable_str( $params['conference_url'] ?? null, null ), conferenceLabel: $this->nullable_str( $params['conference_label'] ?? null, null ), venueId: $venue_id, organizerId: $organizer_id ); // 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() ); if ( $replaces ) { $this->exclude_occurrence( $replaces, $this->str( $params['instanceDate'] ) ); } // 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'] ) ); } if ( $event_id && !empty( $params['tags'] ) ) { $this->action_service()->save_tags( $event_id, $this->resolve_tag_ids( $params['tags'] ) ); } if ( $event_id && isset( $params['extras'] ) && is_array( $params['extras'] ) ) { /** @var array $submitted_extras */ $submitted_extras = $params['extras']; $this->container->get_site_extra_service()->saveExtrasForEvent( $event_id, \WebCalendar\WP\SiteExtraFields::filter_values( $submitted_extras ) ); } // 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, ]); } } }