diff --git a/mu-plugins/flavian-headless.php b/mu-plugins/flavian-headless.php index 3502a82..b5f99b5 100644 --- a/mu-plugins/flavian-headless.php +++ b/mu-plugins/flavian-headless.php @@ -17,37 +17,123 @@ exit; } +// Per-blog site-transient cache for cross-site (network) config reads. +const CONFIG_CACHE_PREFIX = 'flavian_headless_cfg_'; +const CONFIG_CACHE_TTL = 5 * MINUTE_IN_SECONDS; + +/** + * Read the headless settings for the current blog, cached per blog for the request. + * + * In multisite get_option() is already per-blog, but a request-static that is not + * keyed by blog id leaks one site's config into another after switch_to_blog(). + * Keying the request cache by blog id keeps each tenant isolated. + * + * @return array Current blog config: enabled (bool), frontend_url (string), preview_secret (string). + */ +function current_settings(): array { + static $cache = array(); + + $blog_id = get_current_blog_id(); + if ( ! isset( $cache[ $blog_id ] ) ) { + $cache[ $blog_id ] = array( + 'enabled' => (bool) get_option( 'flavian_headless_mode', false ), + 'frontend_url' => normalize_frontend_url( (string) get_option( 'flavian_headless_frontend_url', '' ) ), + 'preview_secret' => (string) get_option( 'flavian_headless_preview_secret', '' ), + ); + } + + return $cache[ $blog_id ]; +} + /** - * Cached check so the toggle doesn't hit the options table on every hook. + * Normalize a stored frontend URL: strip a trailing slash, fall back to local dev. + * + * @param string $url Raw stored URL. + * @return string + */ +function normalize_frontend_url( string $url ): string { + return '' !== $url ? rtrim( $url, '/' ) : 'http://localhost:3000'; +} + +/** + * Whether headless mode is enabled for the current blog. * * @return bool */ function is_enabled(): bool { - static $enabled = null; - if ( null === $enabled ) { - $enabled = (bool) get_option( 'flavian_headless_mode', false ); - } - return $enabled; + return current_settings()['enabled']; } /** - * Configured frontend origin without a trailing slash. + * Configured frontend origin without a trailing slash, for the current blog. * * @return string */ function frontend_url(): string { - $url = (string) get_option( 'flavian_headless_frontend_url', '' ); - return '' !== $url ? rtrim( $url, '/' ) : 'http://localhost:3000'; + return current_settings()['frontend_url']; } /** - * Stored preview secret used to authenticate Preview-button clicks. + * Stored preview secret used to authenticate Preview-button clicks, for the current blog. * * @return string */ function preview_secret(): string { - return (string) get_option( 'flavian_headless_preview_secret', '' ); + return current_settings()['preview_secret']; +} + +/** + * Read another blog's public headless config without leaking the current blog's. + * + * Network-level views (e.g. the network dashboard widget) need a sub-site's + * config. This switches into the target blog to read its per-site options, always + * restores, and caches the non-sensitive result in a per-blog site transient so a + * network screen listing many sites does not switch on every render. The preview + * secret is intentionally excluded — it never needs to leave its own site. + * + * @param int $blog_id Target blog id. + * @return array Public config: enabled (bool), frontend_url (string). + */ +function settings_for_blog( int $blog_id ): array { + if ( ! is_multisite() || get_current_blog_id() === $blog_id ) { + $current = current_settings(); + return array( + 'enabled' => $current['enabled'], + 'frontend_url' => $current['frontend_url'], + ); + } + + $cache_key = CONFIG_CACHE_PREFIX . $blog_id; + $cached = get_site_transient( $cache_key ); + if ( is_array( $cached ) ) { + return $cached; + } + + switch_to_blog( $blog_id ); + $settings = array( + 'enabled' => (bool) get_option( 'flavian_headless_mode', false ), + 'frontend_url' => normalize_frontend_url( (string) get_option( 'flavian_headless_frontend_url', '' ) ), + ); + restore_current_blog(); + + set_site_transient( $cache_key, $settings, CONFIG_CACHE_TTL ); + return $settings; +} + +/** + * Drop the cross-site config cache for a blog when its headless options change. + * + * The update_option_* hooks fire on the blog whose option changed, so the current + * blog id identifies the transient to invalidate. + * + * @return void + */ +function flush_config_cache(): void { + delete_site_transient( CONFIG_CACHE_PREFIX . get_current_blog_id() ); } +add_action( 'update_option_flavian_headless_mode', __NAMESPACE__ . '\\flush_config_cache' ); +add_action( 'update_option_flavian_headless_frontend_url', __NAMESPACE__ . '\\flush_config_cache' ); +add_action( 'update_option_flavian_headless_preview_secret', __NAMESPACE__ . '\\flush_config_cache' ); /** * Origins allowed to make credentialed cross-origin requests. diff --git a/mu-plugins/flavian-multisite.php b/mu-plugins/flavian-multisite.php index 2f6b464..94669ab 100644 --- a/mu-plugins/flavian-multisite.php +++ b/mu-plugins/flavian-multisite.php @@ -77,6 +77,36 @@ function register_network_dashboard_widget(): void { } add_action( 'wp_network_dashboard_setup', __NAMESPACE__ . '\\register_network_dashboard_widget' ); +/** + * Build a "headless" badge for a site, or '' when headless mode is off / unknown. + * + * Reads the target site's config through the headless plugin's isolated accessor + * (which uses switch_to_blog() + a per-blog site transient), so one site's config + * never leaks into another's row. + * + * @param int $blog_id Target blog id. + * @return string Escaped HTML fragment, or '' when not applicable. + */ +function headless_badge( int $blog_id ): string { + if ( ! function_exists( 'Flavian\\Headless\\settings_for_blog' ) ) { + return ''; + } + $config = \Flavian\Headless\settings_for_blog( $blog_id ); + if ( empty( $config['enabled'] ) ) { + return ''; + } + return sprintf( + ' %s', + esc_html( + sprintf( + /* translators: %s: configured headless frontend URL. */ + __( 'headless → %s', 'flavian' ), + $config['frontend_url'] + ) + ) + ); +} + /** * Render the body of the network sites dashboard widget. * @@ -96,12 +126,15 @@ function render_network_dashboard_widget(): void { if ( ! $details ) { continue; } + echo '
%3$s%3$s',
esc_url( get_admin_url( $blog_id ) ),
esc_html( $details->blogname ),
esc_html( $details->siteurl )
);
+ echo wp_kses_post( headless_badge( $blog_id ) );
+ echo '';
}
echo '';
}