'' !== (string) $value && '0' !== (string) $value ); $base = self::pretty() ? home_url( '/' . self::PATH ) : add_query_arg( self::VAR, '1', home_url( '/' ) ); return array() === $args ? $base : add_query_arg( array_map( 'rawurlencode', array_map( 'strval', $args ) ), $base ); } public static function course_url( int $course_id, CourseVisibility $visibility, int $user_id ): string { if ( $course_id <= 0 ) { return ''; } if ( CourseVisibility::PUBLIC === $visibility ) { return self::url( array( 'course' => $course_id ) ); } if ( $user_id <= 0 ) { return ''; } return self::url( array( 'course' => $course_id, 'u' => $user_id, 't' => FeedTokens::sign( $user_id, $course_id ), ) ); } public static function parse_request( $wp ): void { $vars = $wp instanceof \WP && is_array( $wp->query_vars ) ? $wp->query_vars : array(); if ( empty( $vars[ self::VAR ] ) ) { return; } $arg = static function ( string $key ): string { $raw = \RoosterLMS\UI\TableRequest::get( $key ); return is_scalar( $raw ) ? sanitize_text_field( (string) $raw ) : ''; }; $etag = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_IF_NONE_MATCH'] ) ) ) : ''; $response = self::respond( array( 'category' => absint( $arg( 'category' ) ), 'course' => absint( $arg( 'course' ) ), 'occurrence' => absint( $arg( 'occurrence' ) ), 'u' => absint( $arg( 'u' ) ), 't' => $arg( 't' ), ), $etag ); status_header( $response['status'] ); foreach ( $response['headers'] as $name => $value ) { header( $name . ': ' . $value ); } \RoosterLMS\Core\Output::body( $response['body'] ); exit; } public static function respond( array $args, string $etag = '' ): array { $plain = array( 'Content-Type' => 'text/plain; charset=utf-8', 'X-Content-Type-Options' => 'nosniff', 'X-Robots-Tag' => 'noindex, nofollow', 'Cache-Control' => 'no-store', ); if ( ! FeedRate::hit() ) { return array( 'status' => 429, 'headers' => $plain + array( 'Retry-After' => (string) FeedRate::WINDOW ), 'body' => "Too many requests.\n", ); } $user = 0; $who = EventRepository::GUEST; if ( (int) $args['u'] > 0 || '' !== (string) $args['t'] ) { if ( (int) $args['course'] <= 0 || ! FeedTokens::verify( (int) $args['u'], (int) $args['course'], (string) $args['t'] ) || ! get_userdata( (int) $args['u'] ) instanceof \WP_User ) { return array( 'status' => 404, 'headers' => $plain, 'body' => "Not found.\n", ); } $user = (int) $args['u']; $who = EventRepository::FEED_USER; } $text = self::calendar( (int) $args['category'], (int) $args['course'], (int) $args['occurrence'], $user, $who ); $tag = '"' . md5( $text ) . '"'; $headers = array( 'Content-Type' => 'text/calendar; charset=utf-8', 'Content-Disposition' => 'inline; filename="' . ( (int) $args['occurrence'] > 0 ? 'event-' . (int) $args['occurrence'] : 'events' ) . '.ics"', 'Cache-Control' => ( $user > 0 ? 'private' : 'public' ) . ', max-age=' . self::MAX_AGE, 'ETag' => $tag, 'X-Content-Type-Options' => 'nosniff', 'X-Robots-Tag' => 'noindex, nofollow', ); if ( '' !== $etag && in_array( $tag, array_map( 'trim', explode( ',', $etag ) ), true ) ) { return array( 'status' => 304, 'headers' => $headers, 'body' => '', ); } return array( 'status' => 200, 'headers' => $headers, 'body' => $text, ); } public static function calendar( int $term_id, int $course_id, int $occurrence, int $user_id, string $who ): string { $q = new EventQuery( $term_id > 0 ? array( $term_id ) : array(), array(), '', '', 0, '', self::WINDOW_MONTHS, self::MAX_EVENTS, 1, false, $course_id, 0, $occurrence ); $now = new \DateTimeImmutable( '@' . Clock::timestamp() ); $events = EventRepository::find( $q, $user_id, $now, $who, true, self::MAX_EVENTS, 0 ); $posts = EventPosts::of( $events ); $name = wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ); if ( $course_id > 0 && array() !== $events ) { $name = (string) ( $posts[ $events[0]->id() ]['course_title'] ?? $name ); } $lines = array( 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:' . self::PRODID, 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'X-WR-CALNAME:' . Ics::text( $name ), 'REFRESH-INTERVAL;VALUE=DURATION:PT6H', 'X-PUBLISHED-TTL:PT6H', ); foreach ( $events as $e ) { $lines = array_merge( $lines, self::vevent( $e, $posts[ $e->id() ] ?? array() ) ); } $lines[] = 'END:VCALENDAR'; return implode( "\r\n", array_map( array( Ics::class, 'fold' ), $lines ) ) . "\r\n"; } public static function vevent( Event $e, array $post ): array { $cancelled = $e->cancelled(); $page = CalendarLinks::assert_public( (string) ( $post['url'] ?? '' ) ); $stamp = '' !== $e->session->updated_at ? $e->session->updated_at : $e->occurrence->starts_at; $lines = array( 'BEGIN:VEVENT', 'UID:' . History::uid( $e->id() ), 'SEQUENCE:' . History::sequence( $e->id(), $cancelled ), 'DTSTAMP:' . Ics::utc( $stamp ), 'DTSTART:' . Ics::utc( $e->occurrence->starts_at ), 'DTEND:' . Ics::utc( $e->occurrence->ends_at ), 'SUMMARY:' . Ics::text( (string) ( $post['title'] ?? __( 'Live lesson', 'rooster-lms' ) ) ), 'LOCATION:' . Ics::text( $e->location() ), ); if ( '' !== $page ) { $lines[] = 'DESCRIPTION:' . Ics::text( $page ); $lines[] = 'URL:' . Ics::uri( $page ); } $lines[] = 'STATUS:' . ( $cancelled ? 'CANCELLED' : 'CONFIRMED' ); $lines[] = 'END:VEVENT'; return $lines; } public static function pretty(): bool { global $wp_rewrite; if ( ! $wp_rewrite instanceof \WP_Rewrite || ! $wp_rewrite->using_permalinks() ) { return false; } $rules = get_option( 'rewrite_rules' ); $rule = is_array( $rules ) ? ( $rules['^rooster-lms/events\.ics$'] ?? '' ) : ''; return str_contains( (string) $rule, self::VAR . '=' ); } }