Deploy d78347d from main
This commit is contained in:
@@ -46,7 +46,7 @@ class Peptide_News_Admin {
|
|||||||
// Chart.js for analytics dashboard.
|
// Chart.js for analytics dashboard.
|
||||||
wp_enqueue_script(
|
wp_enqueue_script(
|
||||||
'chartjs',
|
'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(),
|
array(),
|
||||||
'4.4.0',
|
'4.4.0',
|
||||||
true
|
true
|
||||||
@@ -250,7 +250,7 @@ class Peptide_News_Admin {
|
|||||||
'rss_enabled' => 'absint',
|
'rss_enabled' => 'absint',
|
||||||
'rss_feeds' => 'sanitize_textarea_field',
|
'rss_feeds' => 'sanitize_textarea_field',
|
||||||
'newsapi_enabled' => 'absint',
|
'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',
|
'article_retention' => 'absint',
|
||||||
'analytics_retention' => 'absint',
|
'analytics_retention' => 'absint',
|
||||||
'anonymize_ip' => 'absint',
|
'anonymize_ip' => 'absint',
|
||||||
@@ -262,7 +262,7 @@ class Peptide_News_Admin {
|
|||||||
'filter_body_keywords' => 'sanitize_textarea_field',
|
'filter_body_keywords' => 'sanitize_textarea_field',
|
||||||
'filter_blocked_domains' => 'sanitize_textarea_field',
|
'filter_blocked_domains' => 'sanitize_textarea_field',
|
||||||
'llm_enabled' => 'absint',
|
'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_keywords_model' => array( $this, 'sanitize_model_id' ),
|
||||||
'llm_summary_model' => array( $this, 'sanitize_model_id' ),
|
'llm_summary_model' => array( $this, 'sanitize_model_id' ),
|
||||||
'llm_max_articles' => 'absint',
|
'llm_max_articles' => 'absint',
|
||||||
@@ -295,6 +295,44 @@ class Peptide_News_Admin {
|
|||||||
return $value;
|
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 ---
|
// --- Field renderers ---
|
||||||
|
|
||||||
public function render_fetch_interval_field() {
|
public function render_fetch_interval_field() {
|
||||||
@@ -368,9 +406,11 @@ class Peptide_News_Admin {
|
|||||||
|
|
||||||
public function render_newsapi_key_field() {
|
public function render_newsapi_key_field() {
|
||||||
$value = get_option( 'peptide_news_newsapi_key', '' );
|
$value = get_option( 'peptide_news_newsapi_key', '' );
|
||||||
|
$display_value = ! empty( $value ) ? $this->mask_api_key( $value ) : '';
|
||||||
printf(
|
printf(
|
||||||
'<input type="password" name="peptide_news_newsapi_key" value="%s" class="regular-text" />',
|
'<input type="password" name="peptide_news_newsapi_key" value="%s" class="regular-text" placeholder="%s" />',
|
||||||
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() {
|
public function render_openrouter_key_field() {
|
||||||
$value = get_option( 'peptide_news_openrouter_api_key', '' );
|
$value = get_option( 'peptide_news_openrouter_api_key', '' );
|
||||||
|
$display_value = ! empty( $value ) ? $this->mask_api_key( $value ) : '';
|
||||||
printf(
|
printf(
|
||||||
'<input type="password" name="peptide_news_openrouter_api_key" value="%s" class="regular-text" />',
|
'<input type="password" name="peptide_news_openrouter_api_key" value="%s" class="regular-text" placeholder="%s" />',
|
||||||
esc_attr( $value )
|
esc_attr( $display_value ),
|
||||||
|
esc_attr__( 'Enter new API key to change', 'peptide-news' )
|
||||||
);
|
);
|
||||||
echo '<p class="description">' . wp_kses(
|
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' ),
|
__( '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.
|
// 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 = new Peptide_News_Fetcher();
|
||||||
$fetcher->fetch_all_sources();
|
$fetcher->fetch_all_sources();
|
||||||
echo '<div class="notice notice-success"><p>' . esc_html__( 'Fetch completed!', 'peptide-news' ) . '</p></div>';
|
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_clicks );
|
||||||
dbDelta( $sql_daily );
|
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 );
|
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' );
|
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();
|
$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).
|
// Optionally anonymize IP (GDPR-friendly).
|
||||||
if ( get_option( 'peptide_news_anonymize_ip', 1 ) ) {
|
if ( get_option( 'peptide_news_anonymize_ip', 1 ) ) {
|
||||||
$ip = self::anonymize_ip( $ip );
|
$ip = self::anonymize_ip( $ip );
|
||||||
@@ -287,7 +292,7 @@ class Peptide_News_Analytics {
|
|||||||
/**
|
/**
|
||||||
* Get the client IP address.
|
* Get the client IP address.
|
||||||
*
|
*
|
||||||
* @return string
|
* @return string|null
|
||||||
*/
|
*/
|
||||||
private static function get_client_ip() {
|
private static function get_client_ip() {
|
||||||
$ip_keys = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' );
|
$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.
|
// Log the fetch result.
|
||||||
update_option( 'peptide_news_last_fetch', array(
|
update_option( 'peptide_news_last_fetch', array(
|
||||||
'time' => current_time( 'mysql' ),
|
'time' => current_time( 'mysql' ),
|
||||||
@@ -139,18 +146,49 @@ class Peptide_News_Fetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$max_items = $feed->get_item_quantity( 50 );
|
$max_items = $feed->get_item_quantity( 50 );
|
||||||
$items = $feed->get_items( 0, $max_items );
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ( $items as $item ) {
|
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' );
|
$pub_date = $item->get_date( 'Y-m-d H:i:s' );
|
||||||
if ( empty( $pub_date ) ) {
|
if ( empty( $pub_date ) ) {
|
||||||
$pub_date = current_time( 'mysql' );
|
$pub_date = current_time( 'mysql' );
|
||||||
}
|
}
|
||||||
|
|
||||||
$article_url = esc_url_raw( $item->get_permalink() );
|
$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 );
|
$source_name = $this->resolve_article_source( $item, $feed_url, $article_url );
|
||||||
|
|
||||||
$articles[] = array(
|
return array(
|
||||||
'source' => $source_name,
|
'source' => $source_name,
|
||||||
'source_url' => $article_url,
|
'source_url' => $article_url,
|
||||||
'title' => sanitize_text_field( $item->get_title() ),
|
'title' => sanitize_text_field( $item->get_title() ),
|
||||||
@@ -164,10 +202,6 @@ class Peptide_News_Fetcher {
|
|||||||
'language' => 'en',
|
'language' => 'en',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return $articles;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch articles from NewsAPI.org.
|
* 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.
|
* Extract categories from a SimplePie item.
|
||||||
*
|
*
|
||||||
@@ -427,12 +477,25 @@ class Peptide_News_Fetcher {
|
|||||||
if ( method_exists( $http_response, 'get_response_object' ) ) {
|
if ( method_exists( $http_response, 'get_response_object' ) ) {
|
||||||
$raw = $http_response->get_response_object();
|
$raw = $http_response->get_response_object();
|
||||||
if ( isset( $raw->url ) ) {
|
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 {
|
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. */
|
/** @var int Maximum retries for rate-limited requests. */
|
||||||
const MAX_RETRIES = 1;
|
const MAX_RETRIES = 1;
|
||||||
|
|
||||||
/** @var int Maximum elapsed seconds before stopping a batch. */
|
/** @var int Maximum elapsed seconds before stopping a batch. */
|
||||||
const BATCH_TIMEOUT = 120;
|
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.
|
* Check whether LLM processing is enabled and configured.
|
||||||
*
|
*
|
||||||
@@ -276,7 +282,7 @@ class Peptide_News_LLM {
|
|||||||
$backoff = 2; // Initial backoff in seconds.
|
$backoff = 2; // Initial backoff in seconds.
|
||||||
|
|
||||||
while ( $retries <= self::MAX_RETRIES ) {
|
while ( $retries <= self::MAX_RETRIES ) {
|
||||||
$response = wp_remote_post( self::API_URL, array(
|
$response = wp_remote_post( self::get_api_url(), array(
|
||||||
'timeout' => 15,
|
'timeout' => 15,
|
||||||
'headers' => array(
|
'headers' => array(
|
||||||
'Authorization' => 'Bearer ' . $api_key,
|
'Authorization' => 'Bearer ' . $api_key,
|
||||||
@@ -406,9 +412,8 @@ class Peptide_News_LLM {
|
|||||||
*/
|
*/
|
||||||
public static function ajax_generate_summaries() {
|
public static function ajax_generate_summaries() {
|
||||||
check_ajax_referer( 'peptide_news_admin', 'nonce' );
|
check_ajax_referer( 'peptide_news_admin', 'nonce' );
|
||||||
|
|
||||||
if ( ! current_user_can( 'manage_options' ) ) {
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
wp_send_json_error( 'Unauthorized', 403 );
|
wp_die( esc_html__( 'Unauthorized', 'peptide-news' ), 403 );
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( ! self::is_enabled() ) {
|
if ( ! self::is_enabled() ) {
|
||||||
|
|||||||
@@ -124,17 +124,30 @@ class Peptide_News_Rest_API {
|
|||||||
/**
|
/**
|
||||||
* GET /articles
|
* GET /articles
|
||||||
*
|
*
|
||||||
* Returns active articles with no-cache headers so that
|
* Returns active articles with server-side transient caching.
|
||||||
* LiteSpeed / CDN layers always serve fresh data.
|
* Cache is invalidated whenever new articles are stored.
|
||||||
*/
|
*/
|
||||||
public function get_articles( $request ) {
|
public function get_articles( $request ) {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|
||||||
$count = min( $request->get_param( 'count' ), 100 );
|
$count = absint( min( $request->get_param( 'count' ), 100 ) );
|
||||||
$page = max( $request->get_param( 'page' ), 1 );
|
$page = absint( max( $request->get_param( 'page' ), 1 ) );
|
||||||
$offset = ( $page - 1 ) * $count;
|
|
||||||
$table = $wpdb->prefix . 'peptide_news_articles';
|
$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(
|
$articles = $wpdb->get_results( $wpdb->prepare(
|
||||||
"SELECT id, source, source_url, title, excerpt, ai_summary, author,
|
"SELECT id, source, source_url, title, excerpt, ai_summary, author,
|
||||||
thumbnail_url, thumbnail_local, published_at, categories, tags
|
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" );
|
$data = array(
|
||||||
|
|
||||||
$response = rest_ensure_response( array(
|
|
||||||
'articles' => $articles,
|
'articles' => $articles,
|
||||||
'total' => (int) $total,
|
'total' => (int) $total,
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
'per_page' => $count,
|
'per_page' => $count,
|
||||||
'total_pages' => ceil( $total / $count ),
|
'total_pages' => ceil( $total / $count ),
|
||||||
) );
|
);
|
||||||
|
|
||||||
// Prevent LiteSpeed and browser caches from serving stale article data.
|
// Cache the response for 1 hour.
|
||||||
$response->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0' );
|
set_transient( $cache_key, $data, HOUR_IN_SECONDS );
|
||||||
$response->header( 'X-LiteSpeed-Cache-Control', 'no-cache' );
|
|
||||||
|
$response = rest_ensure_response( $data );
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
@@ -227,6 +245,19 @@ class Peptide_News_Rest_API {
|
|||||||
$source_names[] = $source;
|
$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").
|
// Extract domain-based publisher name (e.g., "News-Medical" from "news-medical.net").
|
||||||
if ( ! empty( $source_url ) ) {
|
if ( ! empty( $source_url ) ) {
|
||||||
$host = wp_parse_url( $source_url, PHP_URL_HOST );
|
$host = wp_parse_url( $source_url, PHP_URL_HOST );
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ class Peptide_News_Public {
|
|||||||
'expires' => time() + 1800,
|
'expires' => time() + 1800,
|
||||||
'path' => '/',
|
'path' => '/',
|
||||||
'secure' => is_ssl(),
|
'secure' => is_ssl(),
|
||||||
'httponly' => false,
|
'httponly' => true,
|
||||||
'samesite' => 'Lax',
|
'samesite' => 'Lax',
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -202,6 +202,9 @@ class Peptide_News_Widget extends WP_Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function form( $instance ) {
|
public function form( $instance ) {
|
||||||
|
if ( ! is_array( $instance ) ) {
|
||||||
|
$instance = array();
|
||||||
|
}
|
||||||
$title = isset( $instance['title'] ) ? $instance['title'] : __( 'Peptide News', 'peptide-news' );
|
$title = isset( $instance['title'] ) ? $instance['title'] : __( 'Peptide News', 'peptide-news' );
|
||||||
$count = isset( $instance['count'] ) ? absint( $instance['count'] ) : 5;
|
$count = isset( $instance['count'] ) ? absint( $instance['count'] ) : 5;
|
||||||
?>
|
?>
|
||||||
|
|||||||
Reference in New Issue
Block a user