From 7464c9cc7b0de32ca06f604060a91c0b13b33eef Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:50:15 -0300 Subject: [PATCH 1/6] feat: update footer-fragment.php Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- inc/footer-fragment.php | 436 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 436 insertions(+) create mode 100644 inc/footer-fragment.php diff --git a/inc/footer-fragment.php b/inc/footer-fragment.php new file mode 100644 index 0000000..a237f8c --- /dev/null +++ b/inc/footer-fragment.php @@ -0,0 +1,436 @@ +add_section( + 'libresign_footer_fragment', + array( + 'title' => __( 'Footer integration', 'libresign' ), + 'priority' => 160, + 'description' => __( 'Configure where the theme should fetch the shared static-site footer fragment.', 'libresign' ), + ) + ); + + $wp_customize->add_setting( + 'libresign_footer_fragment_origins', + array( + 'type' => 'theme_mod', + 'sanitize_callback' => 'libresign_footer_fragment_sanitize_origins', + 'default' => '', + ) + ); + + $wp_customize->add_control( + 'libresign_footer_fragment_origins', + array( + 'label' => __( 'Footer fragment origins', 'libresign' ), + 'section' => 'libresign_footer_fragment', + 'type' => 'textarea', + 'description' => __( 'One origin per line or comma separated. Example: http://localhost:8081 or https://libresign.coop', 'libresign' ), + ) + ); +} +add_action( 'customize_register', 'libresign_footer_fragment_customize_register' ); + +/** + * Clear cached footer payload when theme-level configuration changes. + * + * @return void + */ +function libresign_footer_fragment_flush_cache() { + delete_transient( libresign_footer_fragment_cache_key() ); + delete_option( libresign_footer_fragment_backup_key() ); +} + +add_action( 'customize_save_after', 'libresign_footer_fragment_flush_cache' ); + +/** + * Normalize a footer fragment origin list from theme configuration or env. + * + * Accepted formats: + * - string with one URL + * - string with URLs separated by commas or new lines + * - array of URLs + * + * @param mixed $value Raw configured value. + * @return array + */ +function libresign_footer_fragment_normalize_origins( $value ) { + if ( is_string( $value ) ) { + $value = preg_split( '/[\r\n,]+/', $value ) ?: array(); + } + + if ( ! is_array( $value ) ) { + return array(); + } + + $origins = array(); + + foreach ( $value as $origin ) { + $origin = trim( (string) $origin ); + + if ( '' === $origin ) { + continue; + } + + $parts = wp_parse_url( $origin ); + if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { + continue; + } + + $origins[] = rtrim( $origin, '/' ); + } + + return array_values( array_unique( $origins ) ); +} + +/** + * Resolve candidate origins for the static-site footer fragment. + * + * Configuration priority: + * 1. Theme mod `libresign_footer_fragment_origins` + * 2. Theme mod `libresign_footer_fragment_origin` + * 3. Env `LIBRESIGN_FOOTER_FRAGMENT_ORIGINS` + * 4. Env `LIBRESIGN_FOOTER_FRAGMENT_ORIGIN` + * 5. Option `libresign_footer_fragment_origins` (legacy fallback) + * 6. Option `libresign_footer_fragment_origin` (legacy fallback) + * 7. Public production footer + * + * @return array + */ +function libresign_footer_fragment_origins() { + $configured_origins = array( + get_theme_mod( 'libresign_footer_fragment_origins' ), + get_theme_mod( 'libresign_footer_fragment_origin' ), + getenv( 'LIBRESIGN_FOOTER_FRAGMENT_ORIGINS' ), + getenv( 'LIBRESIGN_FOOTER_FRAGMENT_ORIGIN' ), + get_option( 'libresign_footer_fragment_origins' ), + get_option( 'libresign_footer_fragment_origin' ), + ); + + foreach ( $configured_origins as $configured_origin ) { + $origins = libresign_footer_fragment_normalize_origins( $configured_origin ); + + if ( ! empty( $origins ) ) { + return apply_filters( 'libresign_footer_fragment_origins', $origins ); + } + } + + return apply_filters( + 'libresign_footer_fragment_origins', + array( 'https://libresign.coop' ) + ); +} + +/** + * Normalize a locale/tag into a path-friendly BCP47-style value. + * + * @param string $locale Raw locale/tag. + * @return string + */ +function libresign_footer_fragment_normalize_locale_tag( $locale ) { + $locale = trim( str_replace( '_', '-', (string) $locale ) ); + + if ( '' === $locale ) { + return ''; + } + + $parts = array_values( array_filter( explode( '-', $locale ), 'strlen' ) ); + + if ( empty( $parts ) ) { + return ''; + } + + $parts[0] = strtolower( $parts[0] ); + + foreach ( $parts as $index => $part ) { + if ( 0 === $index ) { + continue; + } + + if ( 2 === strlen( $part ) || 3 === strlen( $part ) ) { + $parts[ $index ] = strtoupper( $part ); + continue; + } + + if ( 4 === strlen( $part ) ) { + $parts[ $index ] = ucfirst( strtolower( $part ) ); + continue; + } + + $parts[ $index ] = $part; + } + + return implode( '-', $parts ); +} + +/** + * Resolve dynamic locale segments that may exist in the static site. + * + * @return array + */ +function libresign_footer_fragment_locale_segments() { + $candidates = array(); + + if ( function_exists( 'pll_current_language' ) ) { + $candidates[] = (string) pll_current_language( 'slug' ); + $candidates[] = (string) pll_current_language( 'locale' ); + } + + $candidates[] = (string) determine_locale(); + $candidates[] = (string) get_locale(); + + $segments = array( '' ); + + foreach ( $candidates as $candidate ) { + $normalized = libresign_footer_fragment_normalize_locale_tag( $candidate ); + + if ( '' === $normalized ) { + continue; + } + + $segments[] = '/' . $normalized; + + $language_only = strtok( $normalized, '-' ); + if ( is_string( $language_only ) && '' !== $language_only ) { + $segments[] = '/' . strtolower( $language_only ); + } + } + + return array_values( array_unique( $segments ) ); +} + +/** + * Resolve candidate URLs for the embeddable footer fragment. + * + * @return array + */ +function libresign_footer_fragment_urls() { + $urls = array(); + $locale_segments = libresign_footer_fragment_locale_segments(); + + foreach ( libresign_footer_fragment_origins() as $origin ) { + $origin = rtrim( (string) $origin, '/' ); + + foreach ( $locale_segments as $locale_segment ) { + if ( '' === $locale_segment ) { + $urls[] = $origin . '/fragments/footer/'; + continue; + } + + $urls[] = $origin . $locale_segment . '/fragments/footer/'; + } + } + + return array_values( array_unique( $urls ) ); +} + +/** + * Build the cache key for the resolved footer fragment. + * + * @return string + */ +function libresign_footer_fragment_cache_key() { + return 'libresign_footer_fragment_' . md5( wp_json_encode( libresign_footer_fragment_urls() ) ); +} + +/** + * Build the backup option key for the footer fragment. + * + * @return string + */ +function libresign_footer_fragment_backup_key() { + return libresign_footer_fragment_cache_key() . '_backup'; +} + +/** + * Parse the footer fragment response. + * + * @param string $html Raw fragment HTML. + * @return array + */ +function libresign_footer_fragment_parse_payload( $html ) { + $payload = array( + 'html' => trim( (string) $html ), + 'css' => '', + 'js' => '', + ); + + if ( preg_match( '/data-fragment-css=["\']([^"\']+)["\']/', $payload['html'], $matches ) ) { + $payload['css'] = $matches[1]; + } + + if ( preg_match( '/data-fragment-js=["\']([^"\']+)["\']/', $payload['html'], $matches ) ) { + $payload['js'] = $matches[1]; + } + + return $payload; +} + +/** + * Fetch the first available footer fragment from the static site. + * + * @return array + */ +function libresign_footer_fragment_fetch_remote_payload() { + foreach ( libresign_footer_fragment_urls() as $url ) { + $response = wp_remote_get( + $url, + array( + 'timeout' => 8, + 'headers' => array( + 'Accept' => 'text/html', + ), + ) + ); + + if ( is_wp_error( $response ) ) { + continue; + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + $body = (string) wp_remote_retrieve_body( $response ); + + if ( 200 === $code && '' !== trim( $body ) ) { + return libresign_footer_fragment_parse_payload( $body ); + } + } + + return array(); +} + +/** + * Resolve the cached footer fragment payload. + * + * @return array + */ +function libresign_footer_fragment_get_payload() { + static $payload = null; + + if ( null !== $payload ) { + return $payload; + } + + $cache_key = libresign_footer_fragment_cache_key(); + $cached = get_transient( $cache_key ); + + if ( is_array( $cached ) && ! empty( $cached['html'] ) ) { + $payload = $cached; + return $payload; + } + + $fetched = libresign_footer_fragment_fetch_remote_payload(); + + if ( ! empty( $fetched['html'] ) ) { + set_transient( $cache_key, $fetched, 6 * HOUR_IN_SECONDS ); + update_option( libresign_footer_fragment_backup_key(), $fetched, false ); + $payload = $fetched; + return $payload; + } + + $backup = get_option( libresign_footer_fragment_backup_key(), array() ); + if ( is_array( $backup ) && ! empty( $backup['html'] ) ) { + $payload = $backup; + return $payload; + } + + $payload = array(); + return $payload; +} + +/** + * Enqueue the footer fragment assets. + * + * @return void + */ +function libresign_footer_fragment_enqueue_assets() { + if ( is_admin() ) { + return; + } + + $payload = libresign_footer_fragment_get_payload(); + + if ( ! empty( $payload['css'] ) ) { + wp_enqueue_style( + 'libresign-site-footer-fragment', + $payload['css'], + array(), + null + ); + } + + if ( ! empty( $payload['js'] ) ) { + wp_enqueue_script( + 'libresign-site-footer-fragment', + $payload['js'], + array(), + null, + true + ); + wp_script_add_data( 'libresign-site-footer-fragment', 'type', 'module' ); + } +} +add_action( 'wp_enqueue_scripts', 'libresign_footer_fragment_enqueue_assets', 30 ); + +/** + * Render the footer fragment HTML. + * + * @return string + */ +function libresign_render_footer_fragment() { + $payload = libresign_footer_fragment_get_payload(); + + if ( empty( $payload['html'] ) ) { + return ''; + } + + return $payload['html']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped +} + +/** + * Replace the footer template part with the static-site fragment. + * + * @param string $block_content Rendered block content. + * @param array $block Parsed block data. + * @return string + */ +function libresign_replace_footer_template_part( $block_content, $block ) { + $slug = isset( $block['attrs']['slug'] ) ? (string) $block['attrs']['slug'] : ''; + + if ( 'footer' !== $slug ) { + return $block_content; + } + + return libresign_render_footer_fragment(); +} +add_filter( 'render_block_core/template-part', 'libresign_replace_footer_template_part', 20, 2 ); \ No newline at end of file From eba4fbb9c6a38374feb633a2932a3ef6c69b561b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:50:15 -0300 Subject: [PATCH 2/6] feat: update functions.php Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- functions.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/functions.php b/functions.php index db8bdcc..91a550f 100644 --- a/functions.php +++ b/functions.php @@ -205,6 +205,8 @@ function libresign_pattern_categories() { add_action( 'init', 'libresign_pattern_categories' ); +require_once __DIR__ . '/inc/footer-fragment.php'; + /** * Return the LibreSign logo URL used as a theme-level fallback. */ From d8611ad085465d431a42e517b87700b127be9c34 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:50:15 -0300 Subject: [PATCH 3/6] feat: update readme.txt Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- readme.txt | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/readme.txt b/readme.txt index 349505a..16dad3d 100644 --- a/readme.txt +++ b/readme.txt @@ -10,7 +10,29 @@ License URI: http://www.gnu.org/licenses/gpl-2.0.html == Description == -Nosso tema +Tema WordPress do LibreSign. + +== Footer integration == + +This theme can render the shared footer published by the static LibreSign site. + +Configure it in: + +- `Appearance > Customize > Footer integration` + +Use `Footer fragment origins` to define one or more origins. + +Priority: + +1. Theme configuration in the Customizer +2. Environment variables +3. Default fallback: `https://libresign.coop` + +== Development notes == + +- Footer integration bootstrap: `inc/footer-fragment.php` +- Main theme bootstrap: `functions.php` +- Static site fragment path: `/fragments/footer/` == Changelog == From 87f03c5f043ae70c594d6cdb9fb666ad930f7ffe Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:08:12 -0300 Subject: [PATCH 4/6] feat: update footer-fragment.php Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- inc/footer-fragment.php | 707 +++++++++++++++++++++++++++++----------- 1 file changed, 509 insertions(+), 198 deletions(-) diff --git a/inc/footer-fragment.php b/inc/footer-fragment.php index a237f8c..c459dd7 100644 --- a/inc/footer-fragment.php +++ b/inc/footer-fragment.php @@ -2,31 +2,60 @@ /** * Footer fragment integration for the LibreSign theme. * - * Keeps the static-site footer bridge isolated from the general theme bootstrap - * so `functions.php` stays readable and responsibilities remain grouped. - * - * Primary configuration lives in the theme Customizer. Environment variables - * are supported as infrastructure-level fallbacks only. + * The static site pushes footer artifacts to WordPress through a signed webhook. + * WordPress persists those artifacts locally and the theme renders only the + * stored files, avoiding runtime fetches back to the static site. */ if ( ! defined( 'ABSPATH' ) ) { exit; } +const LIBRESIGN_FOOTER_WEBHOOK_ROUTE = '/libresign/v1/footer-fragment'; +const LIBRESIGN_FOOTER_ASSET_BASE_TOKEN = '__LIBRESIGN_FOOTER_ASSET_BASE_URL__'; +const LIBRESIGN_FOOTER_DEFAULT_LOCALE_KEY = 'default'; + /** - * Sanitize footer fragment origins stored in theme configuration. + * Sanitize multi-line origin text. * - * @param mixed $value Raw value from the Customizer. + * @param mixed $value Raw value. * @return string */ -function libresign_footer_fragment_sanitize_origins( $value ) { - $origins = libresign_footer_fragment_normalize_origins( $value ); +function libresign_footer_fragment_sanitize_multiline_urls( $value ) { + $values = libresign_footer_fragment_normalize_urls( $value ); - return implode( PHP_EOL, $origins ); + return implode( PHP_EOL, $values ); } /** - * Register footer fragment settings in the WordPress Customizer. + * Sanitize multi-line IP/CIDR allowlist entries. + * + * @param mixed $value Raw value. + * @return string + */ +function libresign_footer_fragment_sanitize_multiline_networks( $value ) { + if ( is_string( $value ) ) { + $value = preg_split( '/[\r\n,]+/', $value ) ?: array(); + } + + if ( ! is_array( $value ) ) { + return ''; + } + + $entries = array(); + foreach ( $value as $entry ) { + $entry = trim( (string) $entry ); + if ( '' === $entry ) { + continue; + } + $entries[] = $entry; + } + + return implode( PHP_EOL, array_values( array_unique( $entries ) ) ); +} + +/** + * Register footer integration settings in the Customizer. * * @param \WP_Customize_Manager $wp_customize Customizer manager. * @return void @@ -37,55 +66,75 @@ function libresign_footer_fragment_customize_register( $wp_customize ) { array( 'title' => __( 'Footer integration', 'libresign' ), 'priority' => 160, - 'description' => __( 'Configure where the theme should fetch the shared static-site footer fragment.', 'libresign' ), + 'description' => __( 'Receive footer artifacts from the static LibreSign site.', 'libresign' ), ) ); $wp_customize->add_setting( - 'libresign_footer_fragment_origins', + 'libresign_footer_webhook_secret', array( 'type' => 'theme_mod', - 'sanitize_callback' => 'libresign_footer_fragment_sanitize_origins', + 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ) ); $wp_customize->add_control( - 'libresign_footer_fragment_origins', + 'libresign_footer_webhook_secret', array( - 'label' => __( 'Footer fragment origins', 'libresign' ), + 'label' => __( 'Footer webhook secret', 'libresign' ), + 'section' => 'libresign_footer_fragment', + 'type' => 'text', + 'description' => sprintf( + /* translators: %s: REST route path */ + __( 'Shared secret used to validate webhook requests sent to %s', 'libresign' ), + LIBRESIGN_FOOTER_WEBHOOK_ROUTE + ), + ) + ); + + $wp_customize->add_setting( + 'libresign_footer_webhook_allowed_ips', + array( + 'type' => 'theme_mod', + 'sanitize_callback' => 'libresign_footer_fragment_sanitize_multiline_networks', + 'default' => '', + ) + ); + + $wp_customize->add_control( + 'libresign_footer_webhook_allowed_ips', + array( + 'label' => __( 'Allowed webhook IPs', 'libresign' ), 'section' => 'libresign_footer_fragment', 'type' => 'textarea', - 'description' => __( 'One origin per line or comma separated. Example: http://localhost:8081 or https://libresign.coop', 'libresign' ), + 'description' => __( 'Optional allowlist. Use one IP or CIDR per line. Leave blank to allow any source IP.', 'libresign' ), ) ); } add_action( 'customize_register', 'libresign_footer_fragment_customize_register' ); /** - * Clear cached footer payload when theme-level configuration changes. + * Flush persisted footer artifacts when config changes. * * @return void */ function libresign_footer_fragment_flush_cache() { - delete_transient( libresign_footer_fragment_cache_key() ); - delete_option( libresign_footer_fragment_backup_key() ); + $base = libresign_footer_fragment_storage_base_dir(); + if ( is_dir( $base ) ) { + libresign_footer_fragment_recursive_delete( $base ); + } } add_action( 'customize_save_after', 'libresign_footer_fragment_flush_cache' ); /** - * Normalize a footer fragment origin list from theme configuration or env. - * - * Accepted formats: - * - string with one URL - * - string with URLs separated by commas or new lines - * - array of URLs + * Normalize URL configuration values. * * @param mixed $value Raw configured value. * @return array */ -function libresign_footer_fragment_normalize_origins( $value ) { +function libresign_footer_fragment_normalize_urls( $value ) { if ( is_string( $value ) ) { $value = preg_split( '/[\r\n,]+/', $value ) ?: array(); } @@ -94,62 +143,20 @@ function libresign_footer_fragment_normalize_origins( $value ) { return array(); } - $origins = array(); - - foreach ( $value as $origin ) { - $origin = trim( (string) $origin ); - - if ( '' === $origin ) { + $urls = array(); + foreach ( $value as $url ) { + $url = trim( (string) $url ); + if ( '' === $url ) { continue; } - - $parts = wp_parse_url( $origin ); + $parts = wp_parse_url( $url ); if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { continue; } - - $origins[] = rtrim( $origin, '/' ); - } - - return array_values( array_unique( $origins ) ); -} - -/** - * Resolve candidate origins for the static-site footer fragment. - * - * Configuration priority: - * 1. Theme mod `libresign_footer_fragment_origins` - * 2. Theme mod `libresign_footer_fragment_origin` - * 3. Env `LIBRESIGN_FOOTER_FRAGMENT_ORIGINS` - * 4. Env `LIBRESIGN_FOOTER_FRAGMENT_ORIGIN` - * 5. Option `libresign_footer_fragment_origins` (legacy fallback) - * 6. Option `libresign_footer_fragment_origin` (legacy fallback) - * 7. Public production footer - * - * @return array - */ -function libresign_footer_fragment_origins() { - $configured_origins = array( - get_theme_mod( 'libresign_footer_fragment_origins' ), - get_theme_mod( 'libresign_footer_fragment_origin' ), - getenv( 'LIBRESIGN_FOOTER_FRAGMENT_ORIGINS' ), - getenv( 'LIBRESIGN_FOOTER_FRAGMENT_ORIGIN' ), - get_option( 'libresign_footer_fragment_origins' ), - get_option( 'libresign_footer_fragment_origin' ), - ); - - foreach ( $configured_origins as $configured_origin ) { - $origins = libresign_footer_fragment_normalize_origins( $configured_origin ); - - if ( ! empty( $origins ) ) { - return apply_filters( 'libresign_footer_fragment_origins', $origins ); - } + $urls[] = rtrim( $url, '/' ); } - return apply_filters( - 'libresign_footer_fragment_origins', - array( 'https://libresign.coop' ) - ); + return array_values( array_unique( $urls ) ); } /** @@ -195,181 +202,332 @@ function libresign_footer_fragment_normalize_locale_tag( $locale ) { } /** - * Resolve dynamic locale segments that may exist in the static site. + * Resolve webhook secret. + * + * @return string + */ +function libresign_footer_fragment_webhook_secret() { + $values = array( + get_theme_mod( 'libresign_footer_webhook_secret' ), + getenv( 'LIBRESIGN_FOOTER_WEBHOOK_SECRET' ), + get_option( 'libresign_footer_webhook_secret' ), + ); + + foreach ( $values as $value ) { + $value = trim( (string) $value ); + if ( '' !== $value ) { + return $value; + } + } + + return ''; +} + +/** + * Resolve allowed webhook IP/CIDR entries. * * @return array */ -function libresign_footer_fragment_locale_segments() { - $candidates = array(); +function libresign_footer_fragment_allowed_ips() { + $values = array( + get_theme_mod( 'libresign_footer_webhook_allowed_ips' ), + getenv( 'LIBRESIGN_FOOTER_WEBHOOK_ALLOWED_IPS' ), + get_option( 'libresign_footer_webhook_allowed_ips' ), + ); - if ( function_exists( 'pll_current_language' ) ) { - $candidates[] = (string) pll_current_language( 'slug' ); - $candidates[] = (string) pll_current_language( 'locale' ); + foreach ( $values as $value ) { + $sanitized = libresign_footer_fragment_sanitize_multiline_networks( $value ); + if ( '' !== $sanitized ) { + return preg_split( '/[\r\n,]+/', $sanitized ) ?: array(); + } } - $candidates[] = (string) determine_locale(); - $candidates[] = (string) get_locale(); + return array(); +} - $segments = array( '' ); +/** + * Register the webhook endpoint. + * + * @return void + */ +function libresign_footer_fragment_register_rest_route() { + register_rest_route( + 'libresign/v1', + '/footer-fragment', + array( + 'methods' => 'POST', + 'callback' => 'libresign_footer_fragment_receive_webhook', + 'permission_callback' => '__return_true', + ) + ); +} +add_action( 'rest_api_init', 'libresign_footer_fragment_register_rest_route' ); - foreach ( $candidates as $candidate ) { - $normalized = libresign_footer_fragment_normalize_locale_tag( $candidate ); +/** + * Receive and persist footer artifacts pushed by the static site. + * + * @param \WP_REST_Request $request REST request. + * @return \WP_REST_Response|\WP_Error + */ +function libresign_footer_fragment_receive_webhook( $request ) { + $secret = libresign_footer_fragment_webhook_secret(); + if ( '' === $secret ) { + return new \WP_Error( 'libresign_footer_secret_missing', __( 'Footer webhook secret is not configured.', 'libresign' ), array( 'status' => 503 ) ); + } - if ( '' === $normalized ) { - continue; - } + $ip = libresign_footer_fragment_request_ip(); + if ( ! libresign_footer_fragment_ip_allowed( $ip ) ) { + return new \WP_Error( 'libresign_footer_ip_denied', __( 'Webhook source IP is not allowed.', 'libresign' ), array( 'status' => 403 ) ); + } - $segments[] = '/' . $normalized; + if ( ! libresign_footer_fragment_check_rate_limit( $ip ) ) { + return new \WP_Error( 'libresign_footer_rate_limited', __( 'Too many footer webhook requests.', 'libresign' ), array( 'status' => 429 ) ); + } - $language_only = strtok( $normalized, '-' ); - if ( is_string( $language_only ) && '' !== $language_only ) { - $segments[] = '/' . strtolower( $language_only ); - } + $body = (string) $request->get_body(); + $timestamp = (string) $request->get_header( 'x-libresign-timestamp' ); + $signature = (string) $request->get_header( 'x-libresign-signature' ); + + if ( ! libresign_footer_fragment_verify_signature( $body, $timestamp, $signature, $secret ) ) { + return new \WP_Error( 'libresign_footer_invalid_signature', __( 'Invalid footer webhook signature.', 'libresign' ), array( 'status' => 403 ) ); } - return array_values( array_unique( $segments ) ); + $payload = json_decode( $body, true ); + if ( ! is_array( $payload ) ) { + return new \WP_Error( 'libresign_footer_invalid_payload', __( 'Footer webhook payload must be valid JSON.', 'libresign' ), array( 'status' => 400 ) ); + } + + $validated = libresign_footer_fragment_validate_payload( $payload ); + if ( is_wp_error( $validated ) ) { + return $validated; + } + + $stored = libresign_footer_fragment_persist_payload( $validated ); + if ( is_wp_error( $stored ) ) { + return $stored; + } + + return rest_ensure_response( + array( + 'status' => 'ok', + 'locale' => $stored['locale'], + 'version' => $stored['version'], + ) + ); } /** - * Resolve candidate URLs for the embeddable footer fragment. + * Validate payload shape. * - * @return array + * @param array $payload Payload. + * @return array|\WP_Error */ -function libresign_footer_fragment_urls() { - $urls = array(); - $locale_segments = libresign_footer_fragment_locale_segments(); +function libresign_footer_fragment_validate_payload( $payload ) { + $required = array( 'locale', 'version', 'generated_at', 'html', 'css', 'js', 'assets' ); + foreach ( $required as $field ) { + if ( ! array_key_exists( $field, $payload ) ) { + return new \WP_Error( 'libresign_footer_missing_field', sprintf( __( 'Missing footer payload field: %s', 'libresign' ), $field ), array( 'status' => 400 ) ); + } + } - foreach ( libresign_footer_fragment_origins() as $origin ) { - $origin = rtrim( (string) $origin, '/' ); + if ( ! is_array( $payload['assets'] ) ) { + return new \WP_Error( 'libresign_footer_invalid_assets', __( 'Footer payload assets must be an array.', 'libresign' ), array( 'status' => 400 ) ); + } - foreach ( $locale_segments as $locale_segment ) { - if ( '' === $locale_segment ) { - $urls[] = $origin . '/fragments/footer/'; - continue; - } + $payload['locale'] = libresign_footer_fragment_normalize_locale_tag( (string) $payload['locale'] ); + $payload['version'] = sanitize_text_field( (string) $payload['version'] ); + $payload['generated_at'] = sanitize_text_field( (string) $payload['generated_at'] ); + $payload['html'] = (string) $payload['html']; + $payload['css'] = (string) $payload['css']; + $payload['js'] = (string) $payload['js']; - $urls[] = $origin . $locale_segment . '/fragments/footer/'; - } - } + return $payload; +} - return array_values( array_unique( $urls ) ); +/** + * Get the storage base dir for footer artifacts. + * + * @return string + */ +function libresign_footer_fragment_storage_base_dir() { + $uploads = wp_upload_dir(); + return trailingslashit( $uploads['basedir'] ) . 'libresign-footer'; } /** - * Build the cache key for the resolved footer fragment. + * Get the storage base URL for footer artifacts. * * @return string */ -function libresign_footer_fragment_cache_key() { - return 'libresign_footer_fragment_' . md5( wp_json_encode( libresign_footer_fragment_urls() ) ); +function libresign_footer_fragment_storage_base_url() { + $uploads = wp_upload_dir(); + return trailingslashit( $uploads['baseurl'] ) . 'libresign-footer'; } /** - * Build the backup option key for the footer fragment. + * Convert locale to storage key. * + * @param string $locale Locale. * @return string */ -function libresign_footer_fragment_backup_key() { - return libresign_footer_fragment_cache_key() . '_backup'; +function libresign_footer_fragment_storage_key( $locale ) { + $locale = libresign_footer_fragment_normalize_locale_tag( $locale ); + return '' === $locale ? LIBRESIGN_FOOTER_DEFAULT_LOCALE_KEY : $locale; } /** - * Parse the footer fragment response. + * Persist a validated footer payload. * - * @param string $html Raw fragment HTML. - * @return array + * @param array $payload Validated payload. + * @return array|\WP_Error */ -function libresign_footer_fragment_parse_payload( $html ) { - $payload = array( - 'html' => trim( (string) $html ), - 'css' => '', - 'js' => '', - ); +function libresign_footer_fragment_persist_payload( $payload ) { + $locale_key = libresign_footer_fragment_storage_key( (string) $payload['locale'] ); + $base_dir = trailingslashit( libresign_footer_fragment_storage_base_dir() ) . $locale_key; + $base_url = trailingslashit( libresign_footer_fragment_storage_base_url() ) . $locale_key; + $asset_dir = trailingslashit( $base_dir ) . 'assets'; + $asset_url = trailingslashit( $base_url ) . 'assets'; + + if ( ! wp_mkdir_p( $asset_dir ) ) { + return new \WP_Error( 'libresign_footer_storage_failed', __( 'Unable to create footer artifact storage directory.', 'libresign' ), array( 'status' => 500 ) ); + } + + foreach ( $payload['assets'] as $asset ) { + $relative_path = libresign_footer_fragment_normalize_asset_path( $asset['path'] ?? '' ); + if ( '' === $relative_path ) { + continue; + } + + $content = base64_decode( (string) ( $asset['content_base64'] ?? '' ), true ); + if ( false === $content ) { + return new \WP_Error( 'libresign_footer_invalid_asset', __( 'Unable to decode footer asset.', 'libresign' ), array( 'status' => 400 ) ); + } - if ( preg_match( '/data-fragment-css=["\']([^"\']+)["\']/', $payload['html'], $matches ) ) { - $payload['css'] = $matches[1]; + $destination = trailingslashit( $asset_dir ) . $relative_path; + wp_mkdir_p( dirname( $destination ) ); + file_put_contents( $destination, $content ); } - if ( preg_match( '/data-fragment-js=["\']([^"\']+)["\']/', $payload['html'], $matches ) ) { - $payload['js'] = $matches[1]; + $html = str_replace( LIBRESIGN_FOOTER_ASSET_BASE_TOKEN, $asset_url, (string) $payload['html'] ); + $css = str_replace( LIBRESIGN_FOOTER_ASSET_BASE_TOKEN, $asset_url, (string) $payload['css'] ); + $js = str_replace( LIBRESIGN_FOOTER_ASSET_BASE_TOKEN, $asset_url, (string) $payload['js'] ); + + file_put_contents( trailingslashit( $base_dir ) . 'footer.html', $html ); + file_put_contents( trailingslashit( $base_dir ) . 'footer.css', $css ); + file_put_contents( trailingslashit( $base_dir ) . 'footer.js', $js ); + + $manifest = array( + 'locale' => (string) $payload['locale'], + 'locale_key' => $locale_key, + 'version' => (string) $payload['version'], + 'generated_at' => (string) $payload['generated_at'], + 'html_file' => 'footer.html', + 'css_file' => 'footer.css', + 'js_file' => 'footer.js', + 'asset_url' => $asset_url, + ); + + file_put_contents( trailingslashit( $base_dir ) . 'manifest.json', wp_json_encode( $manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); + + return array( + 'locale' => (string) $payload['locale'], + 'version' => (string) $payload['version'], + ); +} + +/** + * Normalize a relative asset path. + * + * @param string $path Path. + * @return string + */ +function libresign_footer_fragment_normalize_asset_path( $path ) { + $path = trim( str_replace( '\\', '/', (string) $path ) ); + $path = ltrim( $path, '/' ); + + if ( '' === $path || false !== strpos( $path, '..' ) ) { + return ''; } - return $payload; + return $path; } /** - * Fetch the first available footer fragment from the static site. + * Resolve locale lookup keys for the current request. * - * @return array + * @return array */ -function libresign_footer_fragment_fetch_remote_payload() { - foreach ( libresign_footer_fragment_urls() as $url ) { - $response = wp_remote_get( - $url, - array( - 'timeout' => 8, - 'headers' => array( - 'Accept' => 'text/html', - ), - ) - ); +function libresign_footer_fragment_locale_lookup_keys() { + $candidates = array(); + if ( function_exists( 'pll_current_language' ) ) { + $candidates[] = (string) pll_current_language( 'slug' ); + $candidates[] = (string) pll_current_language( 'locale' ); + } + $candidates[] = (string) determine_locale(); + $candidates[] = (string) get_locale(); - if ( is_wp_error( $response ) ) { + $keys = array(); + foreach ( $candidates as $candidate ) { + $normalized = libresign_footer_fragment_normalize_locale_tag( $candidate ); + if ( '' === $normalized ) { continue; } - - $code = (int) wp_remote_retrieve_response_code( $response ); - $body = (string) wp_remote_retrieve_body( $response ); - - if ( 200 === $code && '' !== trim( $body ) ) { - return libresign_footer_fragment_parse_payload( $body ); + $keys[] = $normalized; + $language_only = strtok( $normalized, '-' ); + if ( is_string( $language_only ) && '' !== $language_only ) { + $keys[] = strtolower( $language_only ); } } - return array(); + $keys[] = LIBRESIGN_FOOTER_DEFAULT_LOCALE_KEY; + return array_values( array_unique( $keys ) ); } /** - * Resolve the cached footer fragment payload. + * Load the best locally stored footer artifact. * - * @return array + * @return array|null */ -function libresign_footer_fragment_get_payload() { - static $payload = null; - - if ( null !== $payload ) { - return $payload; +function libresign_footer_fragment_load_artifact() { + static $artifact = false; + if ( false !== $artifact ) { + return $artifact; } - $cache_key = libresign_footer_fragment_cache_key(); - $cached = get_transient( $cache_key ); + $base = libresign_footer_fragment_storage_base_dir(); + foreach ( libresign_footer_fragment_locale_lookup_keys() as $locale_key ) { + $manifest_path = trailingslashit( $base ) . $locale_key . '/manifest.json'; + if ( ! is_file( $manifest_path ) ) { + continue; + } - if ( is_array( $cached ) && ! empty( $cached['html'] ) ) { - $payload = $cached; - return $payload; - } + $manifest = json_decode( (string) file_get_contents( $manifest_path ), true ); + if ( ! is_array( $manifest ) ) { + continue; + } - $fetched = libresign_footer_fragment_fetch_remote_payload(); + $dir = dirname( $manifest_path ); + $html_path = trailingslashit( $dir ) . ( $manifest['html_file'] ?? 'footer.html' ); + $css_path = trailingslashit( $dir ) . ( $manifest['css_file'] ?? 'footer.css' ); + $js_path = trailingslashit( $dir ) . ( $manifest['js_file'] ?? 'footer.js' ); - if ( ! empty( $fetched['html'] ) ) { - set_transient( $cache_key, $fetched, 6 * HOUR_IN_SECONDS ); - update_option( libresign_footer_fragment_backup_key(), $fetched, false ); - $payload = $fetched; - return $payload; - } + if ( ! is_file( $html_path ) ) { + continue; + } - $backup = get_option( libresign_footer_fragment_backup_key(), array() ); - if ( is_array( $backup ) && ! empty( $backup['html'] ) ) { - $payload = $backup; - return $payload; + $manifest['html_path'] = $html_path; + $manifest['css_path'] = is_file( $css_path ) ? $css_path : ''; + $manifest['js_path'] = is_file( $js_path ) ? $js_path : ''; + $artifact = $manifest; + return $artifact; } - $payload = array(); - return $payload; + $artifact = null; + return $artifact; } /** - * Enqueue the footer fragment assets. + * Enqueue locally stored footer assets. * * @return void */ @@ -378,23 +536,28 @@ function libresign_footer_fragment_enqueue_assets() { return; } - $payload = libresign_footer_fragment_get_payload(); + $artifact = libresign_footer_fragment_load_artifact(); + if ( ! is_array( $artifact ) ) { + return; + } + + $base_url = trailingslashit( libresign_footer_fragment_storage_base_url() ) . $artifact['locale_key']; - if ( ! empty( $payload['css'] ) ) { + if ( ! empty( $artifact['css_path'] ) ) { wp_enqueue_style( 'libresign-site-footer-fragment', - $payload['css'], + trailingslashit( $base_url ) . $artifact['css_file'], array(), - null + $artifact['version'] ?? filemtime( $artifact['css_path'] ) ); } - if ( ! empty( $payload['js'] ) ) { + if ( ! empty( $artifact['js_path'] ) ) { wp_enqueue_script( 'libresign-site-footer-fragment', - $payload['js'], + trailingslashit( $base_url ) . $artifact['js_file'], array(), - null, + $artifact['version'] ?? filemtime( $artifact['js_path'] ), true ); wp_script_add_data( 'libresign-site-footer-fragment', 'type', 'module' ); @@ -403,34 +566,182 @@ function libresign_footer_fragment_enqueue_assets() { add_action( 'wp_enqueue_scripts', 'libresign_footer_fragment_enqueue_assets', 30 ); /** - * Render the footer fragment HTML. + * Render the locally stored footer HTML. * * @return string */ function libresign_render_footer_fragment() { - $payload = libresign_footer_fragment_get_payload(); - - if ( empty( $payload['html'] ) ) { - return ''; + $artifact = libresign_footer_fragment_load_artifact(); + if ( ! is_array( $artifact ) || empty( $artifact['html_path'] ) || ! is_file( $artifact['html_path'] ) ) { + return ''; } - return $payload['html']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + return (string) file_get_contents( $artifact['html_path'] ); } /** - * Replace the footer template part with the static-site fragment. + * Replace the footer template part with the stored footer artifact. * * @param string $block_content Rendered block content. - * @param array $block Parsed block data. + * @param array $block Parsed block. * @return string */ function libresign_replace_footer_template_part( $block_content, $block ) { $slug = isset( $block['attrs']['slug'] ) ? (string) $block['attrs']['slug'] : ''; - if ( 'footer' !== $slug ) { return $block_content; } - return libresign_render_footer_fragment(); + $footer = libresign_render_footer_fragment(); + return '' !== $footer ? $footer : $block_content; +} +add_filter( 'render_block_core/template-part', 'libresign_replace_footer_template_part', 20, 2 ); + +/** + * Get the current webhook source IP. + * + * @return string + */ +function libresign_footer_fragment_request_ip() { + return isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : ''; +} + +/** + * Check whether the webhook IP is allowed. + * + * @param string $ip IP. + * @return bool + */ +function libresign_footer_fragment_ip_allowed( $ip ) { + $allowed = libresign_footer_fragment_allowed_ips(); + if ( empty( $allowed ) ) { + return true; + } + + foreach ( $allowed as $entry ) { + $entry = trim( (string) $entry ); + if ( '' === $entry ) { + continue; + } + if ( false !== strpos( $entry, '/' ) ) { + if ( libresign_footer_fragment_ip_matches_cidr( $ip, $entry ) ) { + return true; + } + continue; + } + if ( $ip === $entry ) { + return true; + } + } + + return false; +} + +/** + * Check if an IP matches a CIDR range. + * + * @param string $ip IP. + * @param string $cidr CIDR. + * @return bool + */ +function libresign_footer_fragment_ip_matches_cidr( $ip, $cidr ) { + list( $subnet, $mask ) = array_pad( explode( '/', $cidr, 2 ), 2, null ); + if ( null === $mask ) { + return false; + } + + $ip_bin = @inet_pton( $ip ); + $subnet_bin = @inet_pton( $subnet ); + if ( false === $ip_bin || false === $subnet_bin || strlen( $ip_bin ) !== strlen( $subnet_bin ) ) { + return false; + } + + $mask = (int) $mask; + $bytes = intdiv( $mask, 8 ); + $bits = $mask % 8; + + if ( 0 !== $bytes && substr( $ip_bin, 0, $bytes ) !== substr( $subnet_bin, 0, $bytes ) ) { + return false; + } + + if ( 0 === $bits ) { + return true; + } + + $mask_byte = ~( 0xff >> $bits ) & 0xff; + return ( ord( $ip_bin[ $bytes ] ) & $mask_byte ) === ( ord( $subnet_bin[ $bytes ] ) & $mask_byte ); +} + +/** + * Simple webhook rate-limit gate. + * + * @param string $ip Source IP. + * @return bool + */ +function libresign_footer_fragment_check_rate_limit( $ip ) { + $key = 'libresign_footer_webhook_rl_' . md5( $ip ?: 'unknown' ); + if ( get_transient( $key ) ) { + return false; + } + set_transient( $key, 1, 5 ); + return true; +} + +/** + * Verify timestamped HMAC signature. + * + * @param string $body Raw request body. + * @param string $timestamp Header timestamp. + * @param string $signature Header signature. + * @param string $secret Shared secret. + * @return bool + */ +function libresign_footer_fragment_verify_signature( $body, $timestamp, $signature, $secret ) { + if ( '' === $timestamp || '' === $signature || '' === $secret ) { + return false; + } + + if ( ! ctype_digit( $timestamp ) ) { + return false; + } + + if ( abs( time() - (int) $timestamp ) > 300 ) { + return false; + } + + $signature = trim( $signature ); + if ( 0 === strpos( $signature, 'sha256=' ) ) { + $signature = substr( $signature, 7 ); + } + + $expected = hash_hmac( 'sha256', $timestamp . "\n" . $body, $secret ); + return hash_equals( $expected, $signature ); +} + +/** + * Recursively delete a directory. + * + * @param string $path Directory path. + * @return void + */ +function libresign_footer_fragment_recursive_delete( $path ) { + if ( ! is_dir( $path ) ) { + return; + } + + $items = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $path, FilesystemIterator::SKIP_DOTS ), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ( $items as $item ) { + $item_path = $item->getPathname(); + if ( $item->isDir() ) { + rmdir( $item_path ); + } else { + unlink( $item_path ); + } + } + + rmdir( $path ); } -add_filter( 'render_block_core/template-part', 'libresign_replace_footer_template_part', 20, 2 ); \ No newline at end of file From ea08b2fc69c9228541870310905f01f502de2ac2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:08:12 -0300 Subject: [PATCH 5/6] docs: update readme.txt Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- readme.txt | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/readme.txt b/readme.txt index 16dad3d..45d9a77 100644 --- a/readme.txt +++ b/readme.txt @@ -20,19 +20,31 @@ Configure it in: - `Appearance > Customize > Footer integration` -Use `Footer fragment origins` to define one or more origins. +Use the theme settings to configure: + +- `Footer webhook secret` +- `Allowed webhook IPs` (optional) Priority: -1. Theme configuration in the Customizer -2. Environment variables -3. Default fallback: `https://libresign.coop` +1. Static site build pushes footer artifacts to the webhook endpoint +2. Theme renders the last stored local artifact +3. If nothing was stored yet, the theme falls back to the original footer template part + +Webhook endpoint: + +- `/wp-json/libresign/v1/footer-fragment` + +The static site build must provide: + +- `LIBRESIGN_FOOTER_WEBHOOK_URL` +- `LIBRESIGN_FOOTER_WEBHOOK_SECRET` == Development notes == - Footer integration bootstrap: `inc/footer-fragment.php` - Main theme bootstrap: `functions.php` -- Static site fragment path: `/fragments/footer/` +- Static site fragment source path: `/fragments/footer/` == Changelog == From 77826a812d8e3a20a3f708125785da3272d3afba Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:20:06 -0300 Subject: [PATCH 6/6] fix: update footer-fragment.php Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- inc/footer-fragment.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/inc/footer-fragment.php b/inc/footer-fragment.php index c459dd7..9ea325d 100644 --- a/inc/footer-fragment.php +++ b/inc/footer-fragment.php @@ -280,11 +280,11 @@ function libresign_footer_fragment_receive_webhook( $request ) { return new \WP_Error( 'libresign_footer_ip_denied', __( 'Webhook source IP is not allowed.', 'libresign' ), array( 'status' => 403 ) ); } - if ( ! libresign_footer_fragment_check_rate_limit( $ip ) ) { + $body = (string) $request->get_body(); + + if ( ! libresign_footer_fragment_check_rate_limit( $ip, $body ) ) { return new \WP_Error( 'libresign_footer_rate_limited', __( 'Too many footer webhook requests.', 'libresign' ), array( 'status' => 429 ) ); } - - $body = (string) $request->get_body(); $timestamp = (string) $request->get_header( 'x-libresign-timestamp' ); $signature = (string) $request->get_header( 'x-libresign-signature' ); @@ -676,10 +676,11 @@ function libresign_footer_fragment_ip_matches_cidr( $ip, $cidr ) { * Simple webhook rate-limit gate. * * @param string $ip Source IP. + * @param string $body Raw request body. * @return bool */ -function libresign_footer_fragment_check_rate_limit( $ip ) { - $key = 'libresign_footer_webhook_rl_' . md5( $ip ?: 'unknown' ); +function libresign_footer_fragment_check_rate_limit( $ip, $body ) { + $key = 'libresign_footer_webhook_rl_' . md5( ($ip ?: 'unknown') . '|' . hash( 'sha256', (string) $body ) ); if ( get_transient( $key ) ) { return false; }