container->get_rate_limiter(); /** @psalm-suppress RedundantCastGivenDocblockType -- $_SERVER may be non-string at runtime */ $remote_addr = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( (string) wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) // @phpstan-ignore cast.string : '127.0.0.1'; $identifier = $remote_addr; // Use User ID if logged in for more precise limiting if ( is_user_logged_in() ) { $raw_uid = get_current_user_id(); /** @phpstan-ignore-next-line */ $identifier = is_scalar( $raw_uid ) ? (string) $raw_uid : '0'; } if ( 'default' !== $bucket ) { $identifier = $bucket . ':' . $identifier; } if ( $limiter->is_exceeded( $identifier, $limit, $period ) ) { return new \WP_Error( 'rest_throttle', __( 'Too many requests. Please try again later.', 'agenticdaisy-calendar' ), [ 'status' => 429, 'reset' => $limiter->get_reset_time( $period ), ] ); } return true; } /** * Register the routes for the objects of the controller. */ public function register_routes(): void { // To be implemented by child classes. } /** * Check if the user has permission to perform the request. * * @param string $capability Required capability. * @return bool|\WP_Error */ protected function check_permission( string $capability ): bool|\WP_Error { if ( ! current_user_can( $capability ) ) { return new \WP_Error( 'rest_forbidden', __( 'You do not have permission to perform this action.', 'agenticdaisy-calendar' ), [ 'status' => 403 ] ); } return true; } /** * Get the logger service. * * @return \WebCalendarDeps\Psr\Log\LoggerInterface */ protected function get_logger(): \WebCalendarDeps\Psr\Log\LoggerInterface { return $this->container->get_logger(); } /** * The response for a delete core refused. * * Core throws DomainException when an entry cannot go: it is missing * (EventNotFoundException), or it still has a ticket order holding * money, which must be cancelled or refunded first. Neither is a server * error. * * @param \DomainException $e The refusal. * @return \WP_Error * * @psalm-suppress PossiblyUnusedMethod -- Called from subclasses via $this->. */ protected function delete_refused( \DomainException $e ): \WP_Error { if ( $e instanceof \WebCalendar\Core\Domain\Exception\EventNotFoundException ) { return new \WP_Error( 'webcal_not_found', $e->getMessage(), [ 'status' => 404 ] ); } return new \WP_Error( 'webcal_conflict', $e->getMessage(), [ 'status' => 409 ] ); } /** * Get the current WordPress user as a core User entity. * * @return User */ protected function get_current_actor(): User { $login = wp_get_current_user()->user_login; $user_repo = $this->container->get_user_repository(); $user = $user_repo->findByLogin( $login ); if ( $user !== null ) { return $user; } // Fallback for system operations (e.g., holidays pseudo-user) return new User( login: $login, firstName: $login, lastName: '', email: $login . '@example.com', isAdmin: true, isEnabled: true ); } /** * Safely get params as an array. * * @param \WP_REST_Request $request REST request. * @return array */ protected function safe_params( \WP_REST_Request $request ): array { return (array) $request->get_params(); } /** * Safely cast a mixed value to string for PHPStan. * * @param mixed $value Value to cast. * @return string */ protected function str( mixed $value ): string { if ( $value === null ) { return ''; } if ( is_scalar( $value ) || ( is_object( $value ) && method_exists( $value, '__toString' ) ) ) { return (string) $value; } return ''; } /** * Write one CSV row with every cell made safe to open in a spreadsheet. * * Excel, LibreOffice and Sheets evaluate a cell that starts with =, +, -, * @, tab or CR as a formula, and event titles, locations and RSVP guest * names are typed by Authors and anonymous visitors. A leading quote * makes the cell plain text (the OWASP CSV-injection mitigation). * * @param resource $handle Stream from fopen(). * @param array $row Cells. * * @psalm-suppress PossiblyUnusedMethod -- Called only from REST route callbacks, which Psalm cannot see registered. */ protected function put_csv_row( $handle, array $row ): void { $safe = array_map( static function ( ?string $cell ): string { $cell = (string) $cell; return ( '' !== $cell && false !== strpbrk( $cell[0], "=+-@\t\r" ) ) ? "'" . $cell : $cell; }, $row ); fputcsv( $handle, $safe ); } /** * Safely cast a mixed value to int for PHPStan. * * @param mixed $value Value to cast. * @return int */ protected function int( mixed $value ): int { if ( $value === null ) { return 0; } if ( is_scalar( $value ) ) { return (int) $value; } return 0; } /** * A trimmed string, or null when it is empty. * * Optional record fields — a venue's postcode, an organizer's phone — * are nullable columns, and a submitted blank means "not set" rather * than an empty string sitting in the database. * * @param mixed $value Value to cast. */ protected function nullable( mixed $value ): ?string { $text = trim( $this->str( $value ) ); return '' === $text ? null : $text; } /** * A float, or null when the value is absent or blank. * * Distinct from a plain cast: (float) '' is 0.0, and 0.0 is a real * latitude on the equator, so an empty field must not silently place a * venue in the Gulf of Guinea. * * @param mixed $value Value to cast. * * @psalm-suppress PossiblyUnusedMethod -- Called from the venue and organizer controllers. */ protected function nullable_float( mixed $value ): ?float { if ( null === $value || '' === $value || ! is_scalar( $value ) ) { return null; } return (float) $value; } /** * A field's submitted value, or what the record already had. * * An absent key means "leave this alone" and a submitted blank means * "clear it" — the distinction a partial update lives or dies by. * * @psalm-suppress PossiblyUnusedMethod -- Called from the venue and organizer controllers. */ protected function kept( \WP_REST_Request $request, string $field, ?string $current ): ?string { return null === $request->get_param( $field ) ? $current : $this->nullable( $request->get_param( $field ) ); } }