| 1 |
<?php |
| 2 |
/** |
| 3 |
* OpenStation — About-tab journal feed. |
| 4 |
* |
| 5 |
* Fetches the public OpenStation RSS feed on demand, normalizes it to |
| 6 |
* plain-text card data, and serves it through an authenticated admin-AJAX |
| 7 |
* request. Keeping the request lazy means opening the shell never waits on |
| 8 |
* the remote blog; the network is touched only when someone visits About. |
| 9 |
* |
| 10 |
* @package OpenStation |
| 11 |
*/ |
| 12 |
|
| 13 |
defined( 'ABSPATH' ) || exit; |
| 14 |
|
| 15 |
const OPENSTATION_ABOUT_FEED_URL = 'https://openstation.blog/feed/'; |
| 16 |
const OPENSTATION_ABOUT_SITE_URL = 'https://openstation.blog/'; |
| 17 |
const OPENSTATION_ABOUT_FEED_CACHE_KEY = 'desktop_mode_about_feed_v1'; |
| 18 |
const OPENSTATION_ABOUT_FEED_STALE_KEY = 'desktop_mode_about_feed_stale_v1'; |
| 19 |
const OPENSTATION_ABOUT_FEED_FAILURE_KEY = 'desktop_mode_about_feed_failure_v1'; |
| 20 |
|
| 21 |
/** |
| 22 |
* Give this one feed a shorter cache than WordPress' default 12 hours. |
| 23 |
* |
| 24 |
* The callback is installed only around our own `fetch_feed()` call and |
| 25 |
* removed immediately afterwards, so dashboard RSS widgets keep their own |
| 26 |
* cache policy. |
| 27 |
* |
| 28 |
* @param int $seconds Existing cache lifetime. |
| 29 |
* @param string $url Feed URL. |
| 30 |
* @return int Cache lifetime in seconds. |
| 31 |
*/ |
| 32 |
function openstation_about_feed_cache_lifetime( $seconds, $url ) { |
| 33 |
return OPENSTATION_ABOUT_FEED_URL === $url ? 30 * MINUTE_IN_SECONDS : $seconds; |
| 34 |
} |
| 35 |
|
| 36 |
/** |
| 37 |
* Bound the cold-feed request so an unavailable journal cannot pin the tab. |
| 38 |
* |
| 39 |
* @param SimplePie $feed Feed parser instance. |
| 40 |
* @param string $url Feed URL. |
| 41 |
*/ |
| 42 |
function openstation_about_feed_options( $feed, $url ) { |
| 43 |
if ( OPENSTATION_ABOUT_FEED_URL === $url && is_callable( array( $feed, 'set_timeout' ) ) ) { |
| 44 |
$feed->set_timeout( 5 ); |
| 45 |
} |
| 46 |
} |
| 47 |
|
| 48 |
/** |
| 49 |
* Collapse remote feed text to one safe, readable line. |
| 50 |
* |
| 51 |
* @param mixed $value Remote feed value. |
| 52 |
* @return string Plain text. |
| 53 |
*/ |
| 54 |
function openstation_about_feed_text( $value ) { |
| 55 |
$text = html_entity_decode( |
| 56 |
wp_strip_all_tags( (string) $value, true ), |
| 57 |
ENT_QUOTES | ENT_HTML5, |
| 58 |
'UTF-8' |
| 59 |
); |
| 60 |
$text = preg_replace( '/\s+/u', ' ', $text ); |
| 61 |
return sanitize_text_field( trim( (string) $text ) ); |
| 62 |
} |
| 63 |
|
| 64 |
/** |
| 65 |
* Convert a parsed SimplePie feed into the small JSON shape the About tab uses. |
| 66 |
* |
| 67 |
* No remote HTML crosses the boundary: titles, author names and excerpts are |
| 68 |
* flattened to text, URLs pass through WordPress' URL sanitizer, and the list |
| 69 |
* is capped before it reaches the browser. |
| 70 |
* |
| 71 |
* @param SimplePie $feed Parsed feed instance. |
| 72 |
* @return array Normalized feed payload. |
| 73 |
*/ |
| 74 |
function openstation_normalize_about_feed( $feed ) { |
| 75 |
$items = array(); |
| 76 |
$feed_items = $feed->get_items( 0, 5 ); |
| 77 |
|
| 78 |
foreach ( $feed_items as $item ) { |
| 79 |
$title = openstation_about_feed_text( $item->get_title() ); |
| 80 |
$url = esc_url_raw( (string) $item->get_permalink() ); |
| 81 |
if ( '' === $title || '' === $url ) { |
| 82 |
continue; |
| 83 |
} |
| 84 |
|
| 85 |
$author = $item->get_author(); |
| 86 |
$author_name = $author ? openstation_about_feed_text( $author->get_name() ) : ''; |
| 87 |
$excerpt = openstation_about_feed_text( $item->get_description() ); |
| 88 |
|
| 89 |
$items[] = array( |
| 90 |
'title' => $title, |
| 91 |
'url' => $url, |
| 92 |
'author' => $author_name, |
| 93 |
'publishedAt' => sanitize_text_field( (string) $item->get_date( 'c' ) ), |
| 94 |
'excerpt' => wp_trim_words( $excerpt, 36, '…' ), |
| 95 |
); |
| 96 |
} |
| 97 |
|
| 98 |
$title = openstation_about_feed_text( $feed->get_title() ); |
| 99 |
$description = openstation_about_feed_text( $feed->get_description() ); |
| 100 |
$home_url = esc_url_raw( (string) $feed->get_link() ); |
| 101 |
|
| 102 |
return array( |
| 103 |
'title' => '' !== $title ? $title : 'OpenStation', |
| 104 |
'description' => '' !== $description |
| 105 |
? $description |
| 106 |
: 'A public dev diary — building a desktop OS for wp-admin.', |
| 107 |
'homeUrl' => '' !== $home_url ? $home_url : OPENSTATION_ABOUT_SITE_URL, |
| 108 |
'feedUrl' => OPENSTATION_ABOUT_FEED_URL, |
| 109 |
'items' => $items, |
| 110 |
'stale' => false, |
| 111 |
); |
| 112 |
} |
| 113 |
|
| 114 |
/** |
| 115 |
* Return the cached journal payload, fetching RSS only when the cache is cold. |
| 116 |
* |
| 117 |
* A last-known-good copy survives for a week. If the blog has a temporary |
| 118 |
* outage, the About tab can still show useful posts and quietly mark the data |
| 119 |
* stale instead of replacing the whole page with an error. |
| 120 |
* |
| 121 |
* @return array|WP_Error Feed payload or a private fetch error. |
| 122 |
*/ |
| 123 |
function openstation_get_about_feed() { |
| 124 |
$cached = get_transient( OPENSTATION_ABOUT_FEED_CACHE_KEY ); |
| 125 |
if ( is_array( $cached ) ) { |
| 126 |
return $cached; |
| 127 |
} |
| 128 |
|
| 129 |
$stale = get_transient( OPENSTATION_ABOUT_FEED_STALE_KEY ); |
| 130 |
if ( false !== get_transient( OPENSTATION_ABOUT_FEED_FAILURE_KEY ) ) { |
| 131 |
if ( is_array( $stale ) ) { |
| 132 |
$stale['stale'] = true; |
| 133 |
return $stale; |
| 134 |
} |
| 135 |
return new WP_Error( 'openstation_about_feed_unavailable' ); |
| 136 |
} |
| 137 |
|
| 138 |
require_once ABSPATH . WPINC . '/feed.php'; |
| 139 |
add_filter( 'wp_feed_cache_transient_lifetime', 'openstation_about_feed_cache_lifetime', 10, 2 ); |
| 140 |
add_action( 'wp_feed_options', 'openstation_about_feed_options', 10, 2 ); |
| 141 |
$feed = fetch_feed( OPENSTATION_ABOUT_FEED_URL ); |
| 142 |
remove_filter( 'wp_feed_cache_transient_lifetime', 'openstation_about_feed_cache_lifetime', 10 ); |
| 143 |
remove_action( 'wp_feed_options', 'openstation_about_feed_options', 10 ); |
| 144 |
|
| 145 |
if ( is_wp_error( $feed ) ) { |
| 146 |
set_transient( OPENSTATION_ABOUT_FEED_FAILURE_KEY, '1', 5 * MINUTE_IN_SECONDS ); |
| 147 |
if ( is_array( $stale ) ) { |
| 148 |
$stale['stale'] = true; |
| 149 |
return $stale; |
| 150 |
} |
| 151 |
return new WP_Error( 'openstation_about_feed_unavailable' ); |
| 152 |
} |
| 153 |
|
| 154 |
$payload = openstation_normalize_about_feed( $feed ); |
| 155 |
set_transient( OPENSTATION_ABOUT_FEED_CACHE_KEY, $payload, 30 * MINUTE_IN_SECONDS ); |
| 156 |
set_transient( OPENSTATION_ABOUT_FEED_STALE_KEY, $payload, WEEK_IN_SECONDS ); |
| 157 |
delete_transient( OPENSTATION_ABOUT_FEED_FAILURE_KEY ); |
| 158 |
return $payload; |
| 159 |
} |
| 160 |
|
| 161 |
/** |
| 162 |
* Serve the latest OpenStation journal posts to the current shell user. |
| 163 |
*/ |
| 164 |
function openstation_ajax_about_feed() { |
| 165 |
check_ajax_referer( 'openstation_about_feed', 'nonce' ); |
| 166 |
|
| 167 |
if ( ! current_user_can( 'read' ) || ! openstation_is_enabled() ) { |
| 168 |
wp_send_json_error( |
| 169 |
array( 'message' => __( 'You cannot load OpenStation updates.', 'desktop-mode' ) ), |
| 170 |
403 |
| 171 |
); |
| 172 |
} |
| 173 |
|
| 174 |
$feed = openstation_get_about_feed(); |
| 175 |
if ( is_wp_error( $feed ) ) { |
| 176 |
wp_send_json_error( |
| 177 |
array( 'message' => __( 'The OpenStation journal is temporarily unavailable.', 'desktop-mode' ) ), |
| 178 |
502 |
| 179 |
); |
| 180 |
} |
| 181 |
|
| 182 |
wp_send_json_success( $feed ); |
| 183 |
} |
| 184 |
add_action( 'wp_ajax_openstation_about_feed', 'openstation_ajax_about_feed' ); |
| 185 |
|