From 759b22ce3bc8b4e7363489b42bd929f0b17f06da Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 8 Apr 2026 04:53:09 +0000 Subject: [PATCH] Deploy d78347d from main --- admin/class-peptide-news-admin.php | 62 ++++++++++-- includes/class-peptide-news-activator.php | 40 ++++++++ includes/class-peptide-news-analytics.php | 9 +- includes/class-peptide-news-fetcher.php | 109 +++++++++++++++++----- includes/class-peptide-news-llm.php | 17 ++-- includes/class-peptide-news-rest-api.php | 55 ++++++++--- public/class-peptide-news-public.php | 5 +- 7 files changed, 245 insertions(+), 52 deletions(-) diff --git a/admin/class-peptide-news-admin.php b/admin/class-peptide-news-admin.php index b4371fb..486a749 100644 --- a/admin/class-peptide-news-admin.php +++ b/admin/class-peptide-news-admin.php @@ -46,7 +46,7 @@ class Peptide_News_Admin { // Chart.js for analytics dashboard. wp_enqueue_script( 'chartjs', - 'https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js', + plugins_url( 'vendor/chartjs/chart.umd.min.js', __FILE__ ), array(), '4.4.0', true @@ -250,7 +250,7 @@ class Peptide_News_Admin { 'rss_enabled' => 'absint', 'rss_feeds' => 'sanitize_textarea_field', 'newsapi_enabled' => 'absint', - 'newsapi_key' => 'sanitize_text_field', + 'newsapi_key' => function ( $value ) { return $this->sanitize_api_key( $value, 'peptide_news_newsapi_key' ); }, 'article_retention' => 'absint', 'analytics_retention' => 'absint', 'anonymize_ip' => 'absint', @@ -262,7 +262,7 @@ class Peptide_News_Admin { 'filter_body_keywords' => 'sanitize_textarea_field', 'filter_blocked_domains' => 'sanitize_textarea_field', 'llm_enabled' => 'absint', - 'openrouter_api_key' => 'sanitize_text_field', + 'openrouter_api_key' => function ( $value ) { return $this->sanitize_api_key( $value, 'peptide_news_openrouter_api_key' ); }, 'llm_keywords_model' => array( $this, 'sanitize_model_id' ), 'llm_summary_model' => array( $this, 'sanitize_model_id' ), 'llm_max_articles' => 'absint', @@ -295,6 +295,44 @@ class Peptide_News_Admin { return $value; } + /** + * Sanitize API keys. If a masked key is submitted (••••••••XXXX format), + * keep the existing key. Otherwise, store the new full key. + * + * @param string $value Raw input. + * @return string Stored API key (full value or existing). + */ + public function sanitize_api_key( $value, $option_name ) { + $value = sanitize_text_field( $value ); + if ( empty( $value ) ) { + return ''; + } + + // If masked format detected (starts with bullets), keep existing stored value. + if ( mb_strpos( $value, '••••••' ) === 0 ) { + return get_option( $option_name, '' ); + } + + return $value; + } + + /** + * Get a masked version of an API key showing only the last 4 characters. + * + * @param string $key Full API key. + * @return string Masked key (e.g., ••••••••XXXX). + */ + private function mask_api_key( $key ) { + if ( empty( $key ) ) { + return ''; + } + $len = strlen( $key ); + if ( $len <= 4 ) { + return str_repeat( '•', $len ); + } + return str_repeat( '•', $len - 4 ) . substr( $key, -4 ); + } + // --- Field renderers --- public function render_fetch_interval_field() { @@ -368,9 +406,11 @@ class Peptide_News_Admin { public function render_newsapi_key_field() { $value = get_option( 'peptide_news_newsapi_key', '' ); + $display_value = ! empty( $value ) ? $this->mask_api_key( $value ) : ''; printf( - '', - esc_attr( $value ) + '', + esc_attr( $display_value ), + esc_attr__( 'Enter new API key to change', 'peptide-news' ) ); } @@ -502,9 +542,11 @@ class Peptide_News_Admin { public function render_openrouter_key_field() { $value = get_option( 'peptide_news_openrouter_api_key', '' ); + $display_value = ! empty( $value ) ? $this->mask_api_key( $value ) : ''; printf( - '', - esc_attr( $value ) + '', + esc_attr( $display_value ), + esc_attr__( 'Enter new API key to change', 'peptide-news' ) ); echo '

' . wp_kses( __( 'Get an API key at openrouter.ai/keys.', 'peptide-news' ), @@ -547,7 +589,11 @@ class Peptide_News_Admin { } // Handle manual fetch trigger. - if ( isset( $_POST['peptide_news_fetch_now'] ) && check_admin_referer( 'peptide_news_fetch_now_action' ) ) { + if ( isset( $_POST['peptide_news_fetch_now'] ) ) { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'Unauthorized', 'peptide-news' ), 403 ); + } + check_admin_referer( 'peptide_news_fetch_now_action' ); $fetcher = new Peptide_News_Fetcher(); $fetcher->fetch_all_sources(); echo '

' . esc_html__( 'Fetch completed!', 'peptide-news' ) . '

'; diff --git a/includes/class-peptide-news-activator.php b/includes/class-peptide-news-activator.php index fb7f024..a05d8a4 100644 --- a/includes/class-peptide-news-activator.php +++ b/includes/class-peptide-news-activator.php @@ -98,6 +98,9 @@ class Peptide_News_Activator { dbDelta( $sql_clicks ); dbDelta( $sql_daily ); + // Add foreign key constraints if they don't exist. + self::add_foreign_key_constraints( $articles_table, $clicks_table, $daily_table ); + update_option( 'peptide_news_db_version', PEPTIDE_NEWS_VERSION ); } @@ -144,4 +147,41 @@ class Peptide_News_Activator { wp_schedule_event( time(), $interval, 'peptide_news_cron_fetch' ); } } + + /** + * Add foreign key constraints to tables. + */ + private static function add_foreign_key_constraints( $articles_table, $clicks_table, $daily_table ) { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $existing_clicks_fk = $wpdb->get_var( + $wpdb->prepare( + "SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'article_id' AND REFERENCED_TABLE_NAME IS NOT NULL LIMIT 1", + $clicks_table + ) + ); + + if ( empty( $existing_clicks_fk ) ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( + "ALTER TABLE `{$clicks_table}` ADD CONSTRAINT `fk_clicks_article_id` FOREIGN KEY (`article_id`) REFERENCES `{$articles_table}`(`id`) ON DELETE CASCADE" + ); + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $existing_stats_fk = $wpdb->get_var( + $wpdb->prepare( + "SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'article_id' AND REFERENCED_TABLE_NAME IS NOT NULL LIMIT 1", + $daily_table + ) + ); + + if ( empty( $existing_stats_fk ) ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( + "ALTER TABLE `{$daily_table}` ADD CONSTRAINT `fk_stats_article_id` FOREIGN KEY (`article_id`) REFERENCES `{$articles_table}`(`id`) ON DELETE CASCADE" + ); + } + } } diff --git a/includes/class-peptide-news-analytics.php b/includes/class-peptide-news-analytics.php index 946a16d..e68467c 100644 --- a/includes/class-peptide-news-analytics.php +++ b/includes/class-peptide-news-analytics.php @@ -23,6 +23,11 @@ class Peptide_News_Analytics { $ip = isset( $meta['ip'] ) ? $meta['ip'] : self::get_client_ip(); + // Skip recording if no valid IP is available. + if ( null === $ip ) { + return false; + } + // Optionally anonymize IP (GDPR-friendly). if ( get_option( 'peptide_news_anonymize_ip', 1 ) ) { $ip = self::anonymize_ip( $ip ); @@ -287,7 +292,7 @@ class Peptide_News_Analytics { /** * Get the client IP address. * - * @return string + * @return string|null */ private static function get_client_ip() { $ip_keys = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' ); @@ -302,7 +307,7 @@ class Peptide_News_Analytics { } } - return '0.0.0.0'; + return null; } /** diff --git a/includes/class-peptide-news-fetcher.php b/includes/class-peptide-news-fetcher.php index a9f03d1..c23a283 100644 --- a/includes/class-peptide-news-fetcher.php +++ b/includes/class-peptide-news-fetcher.php @@ -83,6 +83,13 @@ class Peptide_News_Fetcher { } } + // Clear transient caches after storing new articles. + if ( $stored > 0 ) { + $this->clear_article_cache(); + // Invalidate REST API caches. + $this->invalidate_rest_api_cache(); + } + // Log the fetch result. update_option( 'peptide_news_last_fetch', array( 'time' => current_time( 'mysql' ), @@ -139,36 +146,63 @@ class Peptide_News_Fetcher { } $max_items = $feed->get_item_quantity( 50 ); - $items = $feed->get_items( 0, $max_items ); - foreach ( $items as $item ) { - $pub_date = $item->get_date( 'Y-m-d H:i:s' ); - if ( empty( $pub_date ) ) { - $pub_date = current_time( 'mysql' ); + // Process items in batches of 10 to manage memory usage. + $chunk_size = 10; + for ( $offset = 0; $offset < $max_items; $offset += $chunk_size ) { + $items = $feed->get_items( $offset, $chunk_size ); + + if ( empty( $items ) ) { + break; } - $article_url = esc_url_raw( $item->get_permalink() ); - $source_name = $this->resolve_article_source( $item, $feed_url, $article_url ); - - $articles[] = array( - 'source' => $source_name, - 'source_url' => $article_url, - 'title' => sanitize_text_field( $item->get_title() ), - 'excerpt' => wp_trim_words( wp_strip_all_tags( $item->get_description() ), 40 ), - 'content' => wp_kses_post( $item->get_content() ), - 'author' => sanitize_text_field( $item->get_author() ? $item->get_author()->get_name() : '' ), - 'thumbnail_url' => '', - 'published_at' => $pub_date, - 'categories' => $this->extract_categories( $item ), - 'tags' => '', - 'language' => 'en', - ); + foreach ( $items as $item ) { + $article = $this->process_rss_item( $item, $feed_url ); + if ( ! empty( $article ) ) { + $articles[] = $article; + } + } } } return $articles; } + /** + * Process a single RSS item into an article array. + * + * @param SimplePie_Item $item + * @param string $feed_url + * @return array Article data or empty array if processing failed. + */ + private function process_rss_item( $item, $feed_url ) { + $pub_date = $item->get_date( 'Y-m-d H:i:s' ); + if ( empty( $pub_date ) ) { + $pub_date = current_time( 'mysql' ); + } + + $article_url = esc_url_raw( $item->get_permalink() ); + if ( empty( $article_url ) ) { + return array(); + } + + $source_name = $this->resolve_article_source( $item, $feed_url, $article_url ); + + return array( + 'source' => $source_name, + 'source_url' => $article_url, + 'title' => sanitize_text_field( $item->get_title() ), + 'excerpt' => wp_trim_words( wp_strip_all_tags( $item->get_description() ), 40 ), + 'content' => wp_kses_post( $item->get_content() ), + 'author' => sanitize_text_field( $item->get_author() ? $item->get_author()->get_name() : '' ), + 'thumbnail_url' => '', + 'published_at' => $pub_date, + 'categories' => $this->extract_categories( $item ), + 'tags' => '', + 'language' => 'en', + ); + } + /** * Fetch articles from NewsAPI.org. * @@ -305,6 +339,22 @@ class Peptide_News_Fetcher { ); } + /** + * Invalidate REST API response caches matching the articles pattern. + */ + private function invalidate_rest_api_cache() { + global $wpdb; + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->options} + WHERE option_name LIKE %s + OR option_name LIKE %s", + $wpdb->esc_like( '_transient_peptide_news_articles_' ) . '%', + $wpdb->esc_like( '_transient_timeout_peptide_news_articles_' ) . '%' + ) + ); + } + /** * Extract categories from a SimplePie item. * @@ -427,12 +477,25 @@ class Peptide_News_Fetcher { if ( method_exists( $http_response, 'get_response_object' ) ) { $raw = $http_response->get_response_object(); if ( isset( $raw->url ) ) { - return $raw->url; + $final_url = $raw->url; } } } - return ! empty( $final_url ) ? $final_url : false; + // If we have a final URL, validate that it's not a cross-domain redirect + if ( ! empty( $final_url ) ) { + $original_host = wp_parse_url( $url, PHP_URL_HOST ); + $final_host = wp_parse_url( $final_url, PHP_URL_HOST ); + + // Reject cross-domain redirects + if ( $original_host !== $final_host ) { + return false; + } + + return $final_url; + } + + return false; } /** diff --git a/includes/class-peptide-news-llm.php b/includes/class-peptide-news-llm.php index 5aa8804..f6cb4f3 100644 --- a/includes/class-peptide-news-llm.php +++ b/includes/class-peptide-news-llm.php @@ -9,15 +9,21 @@ */ class Peptide_News_LLM { - /** @var string OpenRouter API endpoint. */ - const API_URL = 'https://openrouter.ai/api/v1/chat/completions'; - /** @var int Maximum retries for rate-limited requests. */ const MAX_RETRIES = 1; /** @var int Maximum elapsed seconds before stopping a batch. */ const BATCH_TIMEOUT = 120; + /** + * Get the OpenRouter API URL from options with a hardcoded default. + * + * @return string + */ + private static function get_api_url() { + return get_option( 'peptide_news_llm_api_url', 'https://openrouter.ai/api/v1/chat/completions' ); + } + /** * Check whether LLM processing is enabled and configured. * @@ -276,7 +282,7 @@ class Peptide_News_LLM { $backoff = 2; // Initial backoff in seconds. while ( $retries <= self::MAX_RETRIES ) { - $response = wp_remote_post( self::API_URL, array( + $response = wp_remote_post( self::get_api_url(), array( 'timeout' => 15, 'headers' => array( 'Authorization' => 'Bearer ' . $api_key, @@ -406,9 +412,8 @@ class Peptide_News_LLM { */ public static function ajax_generate_summaries() { check_ajax_referer( 'peptide_news_admin', 'nonce' ); - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json_error( 'Unauthorized', 403 ); + wp_die( esc_html__( 'Unauthorized', 'peptide-news' ), 403 ); } if ( ! self::is_enabled() ) { diff --git a/includes/class-peptide-news-rest-api.php b/includes/class-peptide-news-rest-api.php index c426b9a..c211edb 100644 --- a/includes/class-peptide-news-rest-api.php +++ b/includes/class-peptide-news-rest-api.php @@ -124,17 +124,30 @@ class Peptide_News_Rest_API { /** * GET /articles * - * Returns active articles with no-cache headers so that - * LiteSpeed / CDN layers always serve fresh data. + * Returns active articles with server-side transient caching. + * Cache is invalidated whenever new articles are stored. */ public function get_articles( $request ) { global $wpdb; - $count = min( $request->get_param( 'count' ), 100 ); - $page = max( $request->get_param( 'page' ), 1 ); - $offset = ( $page - 1 ) * $count; + $count = absint( min( $request->get_param( 'count' ), 100 ) ); + $page = absint( max( $request->get_param( 'page' ), 1 ) ); $table = $wpdb->prefix . 'peptide_news_articles'; + // Build cache key from page and count params. + $cache_key = 'peptide_news_articles_' . $page . '_' . $count; + $cached_response = get_transient( $cache_key ); + if ( false !== $cached_response ) { + return rest_ensure_response( $cached_response ); + } + + $total = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE is_active = %d", 1 ) ); + + // Ensure page doesn't exceed max available pages. + $max_page = max( 1, (int) ceil( $total / $count ) ); + $page = min( $page, $max_page ); + $offset = ( $page - 1 ) * $count; + $articles = $wpdb->get_results( $wpdb->prepare( "SELECT id, source, source_url, title, excerpt, ai_summary, author, thumbnail_url, thumbnail_local, published_at, categories, tags @@ -171,21 +184,26 @@ class Peptide_News_Rest_API { } } } + + // Escape string fields for REST API output + $article->title = wp_kses_post( $article->title ); + $article->excerpt = wp_kses_post( $article->excerpt ); + $article->ai_summary = wp_kses_post( $article->ai_summary ); + $article->author = esc_html( $article->author ); } - $total = $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE is_active = 1" ); - - $response = rest_ensure_response( array( + $data = array( 'articles' => $articles, 'total' => (int) $total, 'page' => $page, 'per_page' => $count, 'total_pages' => ceil( $total / $count ), - ) ); + ); - // Prevent LiteSpeed and browser caches from serving stale article data. - $response->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0' ); - $response->header( 'X-LiteSpeed-Cache-Control', 'no-cache' ); + // Cache the response for 1 hour. + set_transient( $cache_key, $data, HOUR_IN_SECONDS ); + + $response = rest_ensure_response( $data ); return $response; } @@ -227,6 +245,19 @@ class Peptide_News_Rest_API { $source_names[] = $source; } + // Get cached source names list to avoid database queries on every API call. + $cached_sources = get_transient( 'peptide_news_source_names' ); + if ( false === $cached_sources ) { + global $wpdb; + $table = $wpdb->prefix . 'peptide_news_articles'; + $cached_sources = $wpdb->get_col( "SELECT DISTINCT source FROM {$table} WHERE source != '' ORDER BY source" ); + if ( ! is_array( $cached_sources ) ) { + $cached_sources = array(); + } + set_transient( 'peptide_news_source_names', $cached_sources, HOUR_IN_SECONDS ); + } + $source_names = array_merge( $source_names, $cached_sources ); + // Extract domain-based publisher name (e.g., "News-Medical" from "news-medical.net"). if ( ! empty( $source_url ) ) { $host = wp_parse_url( $source_url, PHP_URL_HOST ); diff --git a/public/class-peptide-news-public.php b/public/class-peptide-news-public.php index b99a11a..056c0a1 100644 --- a/public/class-peptide-news-public.php +++ b/public/class-peptide-news-public.php @@ -157,7 +157,7 @@ class Peptide_News_Public { 'expires' => time() + 1800, 'path' => '/', 'secure' => is_ssl(), - 'httponly' => false, + 'httponly' => true, 'samesite' => 'Lax', ) ); @@ -202,6 +202,9 @@ class Peptide_News_Widget extends WP_Widget { } public function form( $instance ) { + if ( ! is_array( $instance ) ) { + $instance = array(); + } $title = isset( $instance['title'] ) ? $instance['title'] : __( 'Peptide News', 'peptide-news' ); $count = isset( $instance['count'] ) ? absint( $instance['count'] ) : 5; ?>