clear_all(); } public static function invalidate_variation( int $variation_id, int $index ): void { self::instance()->clear_all(); } public static function invalidate_option( string $option, $old_value, $value ): void { if ( strpos( $option, 'tdl_gmc_' ) === 0 ) self::instance()->clear_all(); } public static function handle_admin_actions(): void { if ( ! is_admin() || ! current_user_can( THREEDL_GMC_CAP ) ) return; if ( empty( $_GET['tdl_feed_action'] ) || empty( $_GET['_wpnonce'] ) ) return; $action = sanitize_key( wp_unslash( $_GET['tdl_feed_action'] ) ); if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'tdl_feed_admin_action' ) ) return; if ( $action === 'clear_cache' ) { self::instance()->clear_all(); wp_safe_redirect( add_query_arg( 'tdl_feed_notice', 'cache_cleared', remove_query_arg( [ 'tdl_feed_action', '_wpnonce' ] ) ) ); exit; } if ( $action === 'rebuild_cache' ) { $notice = 'rebuilt'; try { self::instance()->rebuild( 'xml' ); } catch ( \Throwable $error ) { $notice = 'rebuild_failed'; } wp_safe_redirect( add_query_arg( 'tdl_feed_notice', $notice, remove_query_arg( [ 'tdl_feed_action', '_wpnonce' ] ) ) ); exit; } } public function serve( string $mode ): void { $path = $this->cache_file( $mode ); $lock = $this->lock_file( $mode ); if ( $this->is_fresh( $path ) ) $this->send_file( $path ); if ( $this->has_valid_lock( $lock ) ) { if ( is_readable( $path ) ) $this->send_file( $path, true ); status_header( 503 ); header( 'Content-Type: text/plain; charset=utf-8' ); header( 'Retry-After: 30' ); echo esc_html__( 'Feed is being generated. Try again in a moment.', '3dl-product-feed-for-google' ); exit; } try { $this->rebuild( $mode ); $this->send_file( $path ); } catch ( \Throwable $error ) { if ( is_readable( $path ) ) $this->send_file( $path, true ); status_header( 500 ); header( 'Content-Type: text/plain; charset=utf-8' ); echo esc_html__( 'Feed generation failed.', '3dl-product-feed-for-google' ); exit; } } public function rebuild( string $mode ): void { $path = $this->cache_file( $mode ); $lock = $this->lock_file( $mode ); if ( $this->has_valid_lock( $lock ) ) { throw new \RuntimeException( 'Feed generation is locked.' ); } $this->acquire_lock( $lock ); try { $xml = $this->generate( $mode ); if ( trim( $xml ) === '' ) throw new \RuntimeException( 'Empty feed output.' ); $this->write_atomic( $path, $xml ); // Diagnostics are useful, but a stats-file write problem must never break the public feed. try { $stats = Feed::instance()->get_stats(); $stats['generated_at'] = time(); $stats['version'] = defined( 'TDL_GMC_LITE_VER' ) ? TDL_GMC_LITE_VER : ''; $this->write_atomic( $this->stats_file( $mode ), (string) wp_json_encode( $stats, JSON_PRETTY_PRINT ) ); } catch ( \Throwable $stats_error ) { // Keep serving the successfully generated XML feed without diagnostics. } } finally { $this->release_lock( $lock ); } } public function clear_all(): void { foreach ( [ 'xml' ] as $mode ) { foreach ( [ $this->cache_file( $mode ), $this->lock_file( $mode ), $this->stats_file( $mode ) ] as $file ) { if ( file_exists( $file ) ) @unlink( $file ); } } } public function cache_info( string $mode ): array { $path = $this->cache_file( $mode ); return [ 'path' => $path, 'exists' => is_readable( $path ), 'mtime' => is_readable( $path ) ? (int) filemtime( $path ) : 0, 'size' => is_readable( $path ) ? (int) filesize( $path ) : 0, 'fresh' => $this->is_fresh( $path ), ]; } public function stats_info( string $mode ): array { $path = $this->stats_file( $mode ); if ( ! is_readable( $path ) ) return []; $decoded = json_decode( (string) file_get_contents( $path ), true ); return is_array( $decoded ) ? $decoded : []; } private function generate( string $mode ): string { if ( ! class_exists( '\\WooCommerce' ) ) throw new \RuntimeException( 'WooCommerce missing.' ); if ( $mode !== 'xml' ) throw new \InvalidArgumentException( 'Unknown feed mode.' ); $renderer = Feed::instance(); if ( function_exists( 'wp_raise_memory_limit' ) ) wp_raise_memory_limit( 'admin' ); if ( function_exists( 'set_time_limit' ) ) @set_time_limit( 120 ); ob_start(); $renderer->output(); return (string) ob_get_clean(); } private function send_file( string $path, bool $stale = false ): void { if ( ! is_readable( $path ) ) { status_header( 404 ); header( 'Content-Type: text/plain; charset=utf-8' ); echo esc_html__( 'Feed cache file not found.', '3dl-product-feed-for-google' ); exit; } if ( ! headers_sent() ) { header( 'Content-Type: application/xml; charset=utf-8' ); header( 'X-TDL-Feed-Cache: ' . ( $stale ? 'STALE' : 'HIT' ) ); header( 'Cache-Control: public, max-age=300, s-maxage=300' ); header( 'Last-Modified: ' . gmdate( 'D, d M Y H:i:s', (int) filemtime( $path ) ) . ' GMT' ); } readfile( $path ); exit; } private function is_fresh( string $path ): bool { return is_readable( $path ) && ( time() - (int) filemtime( $path ) ) < self::TTL; } private function has_valid_lock( string $lock ): bool { return is_readable( $lock ) && ( time() - (int) filemtime( $lock ) ) < self::LOCK_TTL; } private function acquire_lock( string $lock ): void { $dir = dirname( $lock ); if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) throw new \RuntimeException( 'Cannot create feed cache directory.' ); if ( file_put_contents( $lock, (string) time(), LOCK_EX ) === false ) throw new \RuntimeException( 'Cannot create feed lock.' ); } private function release_lock( string $lock ): void { if ( file_exists( $lock ) ) @unlink( $lock ); } private function write_atomic( string $path, string $contents ): void { $dir = dirname( $path ); if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) throw new \RuntimeException( 'Cannot create feed cache directory.' ); $tmp = $path . '.tmp-' . wp_generate_password( 8, false, false ); if ( file_put_contents( $tmp, $contents, LOCK_EX ) === false ) throw new \RuntimeException( 'Cannot write feed cache.' ); @chmod( $tmp, 0644 ); if ( ! @rename( $tmp, $path ) ) { @unlink( $tmp ); throw new \RuntimeException( 'Cannot publish feed cache.' ); } } private function cache_file( string $mode ): string { $upload = wp_get_upload_dir(); $base = trailingslashit( $upload['basedir'] ) . 'tdl-feed-cache'; return trailingslashit( $base ) . $this->file_name( $mode ); } private function stats_file( string $mode ): string { return $this->cache_file( $mode ) . '.stats.json'; } private function lock_file( string $mode ): string { return $this->cache_file( $mode ) . '.lock'; } private function file_name( string $mode ): string { return $mode === 'xml' ? '3dl-gmc-feed.xml' : 'feed.xml'; } }