Deploy d78347d from main
This commit is contained in:
@@ -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(
|
||||
'<input type="password" name="peptide_news_newsapi_key" value="%s" class="regular-text" />',
|
||||
esc_attr( $value )
|
||||
'<input type="password" name="peptide_news_newsapi_key" value="%s" class="regular-text" placeholder="%s" />',
|
||||
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(
|
||||
'<input type="password" name="peptide_news_openrouter_api_key" value="%s" class="regular-text" />',
|
||||
esc_attr( $value )
|
||||
'<input type="password" name="peptide_news_openrouter_api_key" value="%s" class="regular-text" placeholder="%s" />',
|
||||
esc_attr( $display_value ),
|
||||
esc_attr__( 'Enter new API key to change', 'peptide-news' )
|
||||
);
|
||||
echo '<p class="description">' . wp_kses(
|
||||
__( 'Get an API key at <a href="https://openrouter.ai/keys" target="_blank" rel="noopener">openrouter.ai/keys</a>.', '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 '<div class="notice notice-success"><p>' . esc_html__( 'Fetch completed!', 'peptide-news' ) . '</p></div>';
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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() ) {
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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;
|
||||
?>
|
||||
|
||||
Reference in New Issue
Block a user