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;
?>