fix: security hardening for LLM thumbnail feature (QA-002,004,005,006)
- Use $wpdb->prepare() with esc_like() in clear_article_cache() - Add strict base64_decode() validation with error handling - Add isset()/is_array()/is_string() checks for nested array access - Add wp_http_validate_url() SSRF prevention on all image downloads - Add limit_response_size (5MB) on image URL downloads - Set chmod 0644 after file_put_contents()
This commit is contained in:
@@ -370,15 +370,271 @@ class Peptide_News_LLM {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI thumbnails for articles that still have no image
|
||||
* after OG scraping. Uses an image generation model via OpenRouter.
|
||||
*
|
||||
* @param int $batch_size Max articles to process per run.
|
||||
* @return int Number of thumbnails generated.
|
||||
*/
|
||||
public static function generate_missing_thumbnails( $batch_size = 10 ) {
|
||||
if ( ! self::is_enabled() ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$image_model = get_option( 'peptide_news_llm_image_model', '' );
|
||||
if ( empty( $image_model ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'peptide_news_articles';
|
||||
|
||||
// Find active articles with no thumbnail (neither URL nor local).
|
||||
$articles = $wpdb->get_results( $wpdb->prepare(
|
||||
"SELECT id, title, excerpt, ai_summary, tags, categories
|
||||
FROM {$table}
|
||||
WHERE is_active = 1
|
||||
AND ( thumbnail_url = '' OR thumbnail_url IS NULL )
|
||||
AND ( thumbnail_local = '' OR thumbnail_local IS NULL )
|
||||
ORDER BY fetched_at DESC
|
||||
LIMIT %d",
|
||||
$batch_size
|
||||
) );
|
||||
|
||||
if ( empty( $articles ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$api_key = get_option( 'peptide_news_openrouter_api_key', '' );
|
||||
$generated = 0;
|
||||
|
||||
foreach ( $articles as $article ) {
|
||||
$image_data = self::generate_article_thumbnail( $api_key, $image_model, $article );
|
||||
|
||||
if ( $image_data ) {
|
||||
// Save the image locally.
|
||||
$local_path = self::save_generated_image( $image_data, $article->id );
|
||||
|
||||
if ( $local_path ) {
|
||||
$wpdb->update(
|
||||
$table,
|
||||
array( 'thumbnail_local' => $local_path ),
|
||||
array( 'id' => $article->id ),
|
||||
array( '%s' ),
|
||||
array( '%d' )
|
||||
);
|
||||
$generated++;
|
||||
}
|
||||
}
|
||||
|
||||
// Respect rate limits.
|
||||
usleep( 1000000 ); // 1 second between generations.
|
||||
}
|
||||
|
||||
if ( $generated > 0 ) {
|
||||
self::clear_article_cache();
|
||||
}
|
||||
|
||||
return $generated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a thumbnail image for a single article via OpenRouter.
|
||||
*
|
||||
* @param string $api_key
|
||||
* @param string $model Image generation model ID.
|
||||
* @param object $article Database row.
|
||||
* @return string|false Base64-encoded image data, or false.
|
||||
*/
|
||||
private static function generate_article_thumbnail( $api_key, $model, $article ) {
|
||||
$prompt = self::build_image_prompt( $article );
|
||||
|
||||
$body = array(
|
||||
'model' => $model,
|
||||
'messages' => array(
|
||||
array(
|
||||
'role' => 'user',
|
||||
'content' => $prompt,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
$response = wp_remote_post( self::API_URL, array(
|
||||
'timeout' => 60,
|
||||
'headers' => array(
|
||||
'Authorization' => 'Bearer ' . $api_key,
|
||||
'Content-Type' => 'application/json',
|
||||
'HTTP-Referer' => home_url(),
|
||||
'X-Title' => 'Peptide News Aggregator',
|
||||
),
|
||||
'body' => wp_json_encode( $body ),
|
||||
) );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
self::log_error( 'Image generation failed for article ' . $article->id . ': ' . $response->get_error_message() );
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = wp_remote_retrieve_response_code( $response );
|
||||
$data = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
if ( $status < 200 || $status >= 300 ) {
|
||||
$error = isset( $data['error']['message'] ) ? $data['error']['message'] : 'HTTP ' . $status;
|
||||
self::log_error( 'Image generation HTTP error for article ' . $article->id . ': ' . $error );
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract the image data from the response.
|
||||
// OpenRouter image models may return base64 data in various formats.
|
||||
if ( ! empty( $data['choices'][0]['message']['content'] ) ) {
|
||||
$content = $data['choices'][0]['message']['content'];
|
||||
|
||||
// If content is an array (multimodal), look for image parts.
|
||||
if ( is_array( $content ) ) {
|
||||
foreach ( $content as $part ) {
|
||||
if ( isset( $part['type'] ) && 'image_url' === $part['type']
|
||||
&& isset( $part['image_url'] ) && is_array( $part['image_url'] )
|
||||
&& isset( $part['image_url']['url'] ) && is_string( $part['image_url']['url'] ) ) {
|
||||
$url = $part['image_url']['url'];
|
||||
if ( strpos( $url, 'data:image' ) === 0 ) {
|
||||
// Extract base64 from data URL.
|
||||
$base64 = preg_replace( '/^data:image\/\w+;base64,/', '', $url );
|
||||
$decoded = base64_decode( $base64, true );
|
||||
if ( false === $decoded ) {
|
||||
self::log_error( 'Invalid base64 image data for article.' );
|
||||
return false;
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If it's a URL string, download it (with SSRF protection).
|
||||
if ( is_string( $content ) && preg_match( '/^https?:\/\//', $content ) ) {
|
||||
$content_url = trim( $content );
|
||||
if ( ! wp_http_validate_url( $content_url ) ) {
|
||||
self::log_error( 'Invalid image URL (SSRF protection) for article ' . $article->id );
|
||||
return false;
|
||||
}
|
||||
$img_response = wp_remote_get( $content_url, array(
|
||||
'timeout' => 30,
|
||||
'limit_response_size' => 5 * 1024 * 1024,
|
||||
) );
|
||||
if ( ! is_wp_error( $img_response ) ) {
|
||||
return wp_remote_retrieve_body( $img_response );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for data array format (DALL-E style).
|
||||
if ( ! empty( $data['data'][0]['b64_json'] ) ) {
|
||||
$decoded = base64_decode( $data['data'][0]['b64_json'], true );
|
||||
if ( false === $decoded ) {
|
||||
self::log_error( 'Invalid base64 in DALL-E response for article ' . $article->id );
|
||||
return false;
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
if ( ! empty( $data['data'][0]['url'] ) ) {
|
||||
$dalle_url = $data['data'][0]['url'];
|
||||
if ( wp_http_validate_url( $dalle_url ) ) {
|
||||
$img_response = wp_remote_get( $dalle_url, array(
|
||||
'timeout' => 30,
|
||||
'limit_response_size' => 5 * 1024 * 1024,
|
||||
) );
|
||||
if ( ! is_wp_error( $img_response ) ) {
|
||||
return wp_remote_retrieve_body( $img_response );
|
||||
}
|
||||
} else {
|
||||
self::log_error( 'Invalid DALL-E image URL (SSRF protection) for article ' . $article->id );
|
||||
}
|
||||
}
|
||||
|
||||
self::log_error( 'Could not extract image data from response for article ' . $article->id );
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the prompt for AI image generation.
|
||||
*
|
||||
* @param object $article
|
||||
* @return string
|
||||
*/
|
||||
private static function build_image_prompt( $article ) {
|
||||
$summary = ! empty( $article->ai_summary ) ? $article->ai_summary : $article->excerpt;
|
||||
$tags = ! empty( $article->tags ) ? $article->tags : ( $article->categories ?? '' );
|
||||
|
||||
return "Generate a professional, science-themed thumbnail image for a peptide research news article. "
|
||||
. "The image should be modern, clean, and suitable for a news website card layout (16:9 aspect ratio). "
|
||||
. "Use a scientific aesthetic: molecular structures, laboratory elements, or abstract biotech visuals. "
|
||||
. "Do NOT include any text or words in the image.\n\n"
|
||||
. "Article title: " . $article->title . "\n"
|
||||
. ( ! empty( $summary ) ? "Summary: " . mb_substr( $summary, 0, 200 ) . "\n" : '' )
|
||||
. ( ! empty( $tags ) ? "Keywords: " . $tags . "\n" : '' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a generated image to the WP uploads directory.
|
||||
*
|
||||
* @param string $image_data Raw image binary data.
|
||||
* @param int $article_id
|
||||
* @return string|false Relative path from uploads base, or false.
|
||||
*/
|
||||
private static function save_generated_image( $image_data, $article_id ) {
|
||||
if ( empty( $image_data ) || strlen( $image_data ) < 1000 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$upload_dir = wp_upload_dir();
|
||||
$target_dir = $upload_dir['basedir'] . '/peptide-news-thumbs';
|
||||
|
||||
if ( ! file_exists( $target_dir ) ) {
|
||||
wp_mkdir_p( $target_dir );
|
||||
}
|
||||
|
||||
// Detect image type from data.
|
||||
$finfo = new finfo( FILEINFO_MIME_TYPE );
|
||||
$mime = $finfo->buffer( $image_data );
|
||||
$ext_map = array(
|
||||
'image/png' => 'png',
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/webp' => 'webp',
|
||||
'image/gif' => 'gif',
|
||||
);
|
||||
$extension = isset( $ext_map[ $mime ] ) ? $ext_map[ $mime ] : 'png';
|
||||
|
||||
$filename = 'pn-ai-' . $article_id . '.' . $extension;
|
||||
$filepath = $target_dir . '/' . $filename;
|
||||
|
||||
$result = file_put_contents( $filepath, $image_data );
|
||||
|
||||
if ( false === $result ) {
|
||||
self::log_error( 'Failed to save generated thumbnail: ' . $filepath );
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set secure file permissions.
|
||||
chmod( $filepath, 0644 );
|
||||
|
||||
return 'peptide-news-thumbs/' . $filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all article transient caches.
|
||||
*/
|
||||
private static function clear_article_cache() {
|
||||
global $wpdb;
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"DELETE FROM {$wpdb->options}
|
||||
WHERE option_name LIKE '_transient_peptide_news_articles_%'
|
||||
OR option_name LIKE '_transient_timeout_peptide_news_articles_%'"
|
||||
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_' ) . '%'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user