namespace, '/feed', [ [ 'methods' => 'GET', 'callback' => [ $this, 'get_feed' ], // Public subscription endpoint — see register_routes() docblock // for why nonce-based auth cannot apply here and where the // real access-control logic lives. 'permission_callback' => '__return_true', 'args' => [ 'token' => [ 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ], 'category' => [ 'required' => false, 'sanitize_callback' => 'absint', ], ], ], ] ); } /** * Serve the iCal subscription feed. */ public function get_feed( WP_REST_Request $request ): WP_REST_Response|WP_Error { $rate_limit = $this->check_rate_limit( 30, 60, 'feed' ); if ( is_wp_error( $rate_limit ) ) { return $rate_limit; } $token = $this->str( $request->get_param( 'token' ) ); $user = null; $user_id = 0; $scope = EventScope::publicOnly(); if ( $token !== '' ) { $token_manager = $this->get_token_manager(); $user_id = $token_manager->get_user_id_by_token( $token ); if ( $user_id === null ) { return new WP_Error( 'rest_forbidden', __( 'Invalid feed token.', 'agenticdaisy-calendar' ), [ 'status' => 403 ] ); } $user_repo = $this->container->get_user_repository(); $wp_user = get_userdata( $user_id ); if ( $wp_user instanceof \WP_User ) { $user = $user_repo->findByLogin( $wp_user->user_login ); } // Only widen the scope once the account behind the token actually // resolves. This branch used to clear the access level before // knowing that, and a null user with a null access level was // core's administrative path — so a token whose WordPress account // had been deleted answered with every user's Private and // Confidential events, on an endpoint whose permission_callback // is __return_true. Falling back to the public feed is what // FeedControllerTest always said this case did. if ( $user !== null ) { $scope = EventScope::forUser( $user ); } } try { $event_service = $this->container->get_event_service(); $export_svc = $this->container->get_export_service(); $category_repo = $this->container->get_category_repository(); $past_months = (int) apply_filters( 'webcal_feed_past_months', 1 ); $future_months = (int) apply_filters( 'webcal_feed_future_months', 12 ); $now = new DateTimeImmutable(); $range = new DateRange( $now->modify( "-{$past_months} months" ), $now->modify( "+{$future_months} months" ) ); $events = $event_service->getEventsInDateRange( $range, $scope ); // The scope filters on access level only, so pending submissions // come back with it. Keep them off the feed unless the token // owner submitted them or may approve them. $reader_login = $user !== null ? (string) $user->login() : null; $can_approve = $user !== null && user_can( $user_id, 'webcal_can_approve' ); $events = new \WebCalendar\Core\Domain\ValueObject\EventCollection( array_values( array_filter( iterator_to_array( $events, false ), static fn( $event ): bool => EventVisibility::passes_moderation_for( $event, $reader_login, $can_approve ) ) ) ); // Optional category filter $category_id = $this->int( $request->get_param( 'category' ) ); if ( $category_id > 0 ) { $filtered = []; foreach ( $events as $event ) { $cats = $category_repo->getForEvent( $event->id(), $user !== null ? (string) $user->login() : $event->createdBy() ); foreach ( $cats as $cat ) { if ( $cat->id() === $category_id ) { $filtered[] = $event; break; } } } $events = new \WebCalendar\Core\Domain\ValueObject\EventCollection( $filtered ); } // Build the label maps for export. Categories and tags come back // together and are split so X-WEBCAL-TAGS can record which of the // exported names were tags. $category_map = []; $tag_map = []; foreach ( $events as $event ) { $event_id = $event->id()->value(); $split = \WebCalendar\WP\EventLabels::partition( $category_repo->getForEvent( $event->id(), $user !== null ? (string) $user->login() : $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( $events, $category_map, $tag_map ); $this->send_headers( 'Content-Type: text/calendar; charset=utf-8' ); $this->send_headers( 'Cache-Control: no-cache, must-revalidate, max-age=0' ); $this->send_headers( 'X-Robots-Tag: noindex, nofollow' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- ICS text/calendar output, not HTML echo $ics; if ( ! defined( 'PHPUNIT_RUNNING' ) ) { exit; } return new WP_REST_Response( null, 200 ); } catch ( \Throwable $e ) { $this->get_logger()->error( 'REST Error in FeedController (get_feed): ' . $e->getMessage() ); return new WP_Error( 'webcal_error', $e->getMessage(), [ 'status' => 500 ] ); } } /** * Send HTTP headers. * * Extracted to allow mocking in tests. * * @param string $header Header string. */ protected function send_headers( string $header ): void { header( $header ); } /** * Get the token manager instance. * * Extracted as protected for test mockability. */ protected function get_token_manager(): FeedTokenManager { return new FeedTokenManager(); } }