PluginProbe
ezCache / trunk
ezCache vtrunk
2.6.5 2.6.4 2.6.2 2.6.3 2.6.1 2.6.0 2.5.6 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5 2.2.1 2.2.2 trunk 1.2 1.2.1 1.2.2 1.2.3 1.2.4 1.3 1.3.1 1.3.10 1.3.11 All 49 releases
ezcache / includes / Rest / WebpController.php

WebpController.php in ezCache trunk, at includes/Rest/WebpController.php

290 lines 9.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace Upress\EzCache\Rest;
3
4 use Upress\EzCache\Cache;
5 use Upress\EzCache\PremiumFeatures;
6 use Upress\EzCache\WebpApi;
7 use Upress\EzCache\Utilities\Logger;
8
9 class WebpController {
10
11 /**
12 * Generate webp URL matching original WebpConverter format
13 * e.g., image.jpg → image.4a5fec.webp (md5 suffix of extension)
14 */
15 private static function make_webp_name( $path_or_url ) {
16 $ext = pathinfo( $path_or_url, PATHINFO_EXTENSION );
17 $ext_hash = substr( md5( $ext ), -6 );
18 return preg_replace( '/\.' . preg_quote( $ext, '/' ) . '$/', '.' . $ext_hash . '.webp', $path_or_url );
19 }
20
21 function status() {
22 global $wpdb;
23 $table = $wpdb->prefix . 'ezcache_webp_images';
24
25 if ( $wpdb->get_var( "SHOW TABLES LIKE '{$table}'" ) !== $table ) {
26 return [ 'success' => true, 'data' => [
27 'total' => 0, 'completed' => 0, 'pending' => 0, 'failed' => 0,
28 'saved_bytes' => 0, 'is_premium' => PremiumFeatures::is_premium(),
29 ]];
30 }
31
32 $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
33 $completed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE status = 'completed'" );
34 $pending = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE status = 'pending'" );
35 $failed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE status = 'failed'" );
36 $saved = $wpdb->get_row( "SELECT COALESCE(SUM(original_size),0) as orig, COALESCE(SUM(webp_size),0) as webp FROM `{$table}` WHERE status = 'completed'" );
37 $saved_bytes = $saved ? (int)$saved->orig - (int)$saved->webp : 0;
38
39 return [ 'success' => true, 'data' => [
40 'total' => $total, 'completed' => $completed, 'pending' => $pending,
41 'failed' => $failed, 'saved_bytes' => max(0, $saved_bytes),
42 'is_premium' => PremiumFeatures::is_premium(),
43 ]];
44 }
45
46 function scan() {
47 global $wpdb;
48 if ( ! PremiumFeatures::is_premium() ) {
49 return new \WP_Error( 'premium_required', 'Premium license required', [ 'status' => 403 ] );
50 }
51
52 $table = $wpdb->prefix . 'ezcache_webp_images';
53
54 // Self-heal: file-replacement updates can leave this table missing, which
55 // previously made scan silently queue 0 images and still return success.
56 // Create it on demand, and fail loudly if that's not possible.
57 if ( $wpdb->get_var( "SHOW TABLES LIKE '{$table}'" ) !== $table ) {
58 \Upress\EzCache\Updater::ensure_tables();
59 }
60 if ( $wpdb->get_var( "SHOW TABLES LIKE '{$table}'" ) !== $table ) {
61 return new \WP_Error(
62 'ezcache_webp_no_table',
63 'The WebP image table is missing and could not be created automatically. Please check the database user permissions.',
64 [ 'status' => 500 ]
65 );
66 }
67
68 $attachments = $wpdb->get_results(
69 "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'attachment'
70 AND post_mime_type IN ('image/jpeg','image/png','image/gif') ORDER BY ID DESC"
71 );
72
73 $queued = 0;
74 foreach ( $attachments as $att ) {
75 $file = get_attached_file( $att->ID );
76 if ( ! $file || ! file_exists( $file ) ) continue;
77
78 $url = wp_get_attachment_url( $att->ID );
79 $files_to_process = [ [ 'path' => $file, 'url' => $url ] ];
80
81 // Add thumbnails
82 $meta = wp_get_attachment_metadata( $att->ID );
83 if ( isset( $meta['sizes'] ) ) {
84 $dir = dirname( $file );
85 $url_dir = dirname( $url );
86 foreach ( $meta['sizes'] as $data ) {
87 $tp = $dir . '/' . $data['file'];
88 $tu = $url_dir . '/' . $data['file'];
89 if ( file_exists( $tp ) ) {
90 $files_to_process[] = [ 'path' => $tp, 'url' => $tu ];
91 }
92 }
93 }
94
95 foreach ( $files_to_process as $item ) {
96 $uid = sha1( $item['path'] );
97 $exists = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM `{$table}` WHERE uid = %s", $uid ) );
98 if ( $exists ) continue;
99
100 $webp_url = self::make_webp_name( $item['url'] );
101 $webp_path = self::make_webp_name( $item['path'] );
102
103 $wpdb->insert( $table, [
104 'uid' => $uid, 'url' => $item['url'], 'path' => $item['path'],
105 'webp_url' => $webp_url, 'webp_path' => $webp_path,
106 'status' => 'pending',
107 'created_at' => current_time( 'mysql' ),
108 'updated_at' => current_time( 'mysql' ),
109 ]);
110 if ( $wpdb->insert_id ) $queued++;
111 }
112 }
113
114 // Kick off (or re-arm) the background conversion cron whenever anything is
115 // still pending — this both starts a fresh scan and revives a queue that
116 // previously stalled, so conversion completes server-side without the browser.
117 $pending = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE status = 'pending'" );
118 if ( $pending > 0 ) {
119 self::schedule_cron();
120 }
121
122 return [ 'success' => true, 'data' => [
123 'queued' => $queued,
124 'pending' => $pending,
125 'message' => "{$queued} images queued",
126 ] ];
127 }
128
129 function process() {
130 if ( ! PremiumFeatures::is_premium() ) {
131 return new \WP_Error( 'premium_required', 'Premium required', [ 'status' => 403 ] );
132 }
133
134 $result = self::convert_batch( 5 );
135
136 // If done, update existing cache files to point at the WebP URLs.
137 if ( $result['remaining'] === 0 ) {
138 self::update_cache_files();
139 }
140
141 return [ 'success' => true, 'data' => $result ];
142 }
143
144 /**
145 * Convert a batch of pending images. Shared by the REST endpoint and the
146 * background cron so both drive the exact same conversion path.
147 *
148 * @param int $limit
149 * @return array processed/remaining counts.
150 */
151 public static function convert_batch( $limit = 5 ) {
152 global $wpdb;
153 $table = $wpdb->prefix . 'ezcache_webp_images';
154
155 // Self-heal the table so the background process never dies on a missing one.
156 if ( $wpdb->get_var( "SHOW TABLES LIKE '{$table}'" ) !== $table ) {
157 \Upress\EzCache\Updater::ensure_tables();
158 }
159 if ( $wpdb->get_var( "SHOW TABLES LIKE '{$table}'" ) !== $table ) {
160 return [ 'processed' => 0, 'remaining' => 0 ];
161 }
162
163 $pending = $wpdb->get_results( $wpdb->prepare(
164 "SELECT * FROM `{$table}` WHERE status = 'pending' ORDER BY id ASC LIMIT %d", $limit
165 ));
166
167 if ( empty( $pending ) ) {
168 return [ 'processed' => 0, 'remaining' => 0 ];
169 }
170
171 // Freemius removed — Pro is unlocked for everyone.
172 $converter = new WebpApi( 'unlocked_pro' );
173 $processed = 0;
174
175 foreach ( $pending as $image ) {
176 if ( ! file_exists( $image->path ) ) {
177 $wpdb->update( $table, [ 'status' => 'failed', 'updated_at' => current_time('mysql') ], [ 'id' => $image->id ] );
178 continue;
179 }
180
181 $result = $converter->convert( $image->path );
182 if ( is_wp_error( $result ) ) {
183 $wpdb->update( $table, [ 'status' => 'failed', 'updated_at' => current_time('mysql') ], [ 'id' => $image->id ] );
184 Logger::log( "WebP failed: {$image->url}" . $result->get_error_message() );
185 continue;
186 }
187
188 $dir = dirname( $image->webp_path );
189 if ( ! is_dir( $dir ) ) wp_mkdir_p( $dir );
190 file_put_contents( $image->webp_path, $result['data'] );
191
192 $wpdb->update( $table, [
193 'status' => 'completed',
194 'original_size' => filesize( $image->path ),
195 'webp_size' => filesize( $image->webp_path ),
196 'updated_at' => current_time('mysql'),
197 ], [ 'id' => $image->id ] );
198 $processed++;
199 }
200
201 $remaining = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE status = 'pending'" );
202
203 return [ 'processed' => $processed, 'remaining' => $remaining ];
204 }
205
206 /**
207 * WP-Cron callback: convert a batch server-side and re-schedule itself until
208 * the queue drains. This makes conversion resilient — it finishes even if the
209 * browser tab that started the scan is closed.
210 */
211 public static function cron_process() {
212 if ( ! PremiumFeatures::is_premium() ) {
213 return;
214 }
215
216 $result = self::convert_batch( 10 );
217
218 if ( $result['remaining'] > 0 ) {
219 self::schedule_cron();
220 } else {
221 self::update_cache_files();
222 }
223 }
224
225 /**
226 * Schedule (and nudge) the background conversion cron if it isn't already queued.
227 */
228 public static function schedule_cron() {
229 if ( wp_next_scheduled( 'ezcache_webp_process_batch' ) ) {
230 return;
231 }
232 wp_schedule_single_event( time() + 15, 'ezcache_webp_process_batch' );
233
234 // Nudge wp-cron so the batch starts promptly instead of waiting for traffic.
235 wp_safe_remote_get( add_query_arg( [ 'doing_wp_cron' => microtime( true ) ], site_url( '/wp-cron.php' ) ), [
236 'timeout' => 0.1,
237 'blocking' => false,
238 'sslverify' => false,
239 ] );
240 }
241
242 /**
243 * After all conversions done, update cached HTML files to use WebP URLs
244 */
245 private static function update_cache_files() {
246 global $wpdb;
247 $table = $wpdb->prefix . 'ezcache_webp_images';
248
249 $completed = $wpdb->get_results( "SELECT url, webp_url FROM `{$table}` WHERE status = 'completed'" );
250 if ( empty( $completed ) ) return;
251
252 $cache_dir = WP_CONTENT_DIR . '/cache/ezcache/';
253 if ( ! is_dir( $cache_dir ) ) return;
254
255 $cache_files = glob( $cache_dir . '*/*-webp.html.gz' );
256 if ( ! $cache_files ) return;
257
258 foreach ( $cache_files as $file ) {
259 $content = @gzdecode( file_get_contents( $file ) );
260 if ( ! $content ) continue;
261
262 $changed = false;
263 foreach ( $completed as $img ) {
264 if ( strpos( $content, $img->url ) !== false ) {
265 $content = str_replace( $img->url, $img->webp_url, $content );
266 $changed = true;
267 }
268 }
269
270 if ( $changed ) {
271 file_put_contents( $file, gzencode( $content, 6, FORCE_GZIP ) );
272 }
273 }
274 }
275
276 function destroy() {
277 global $wpdb;
278 $table = $wpdb->prefix . 'ezcache_webp_images';
279
280 $images = $wpdb->get_results( "SELECT webp_path FROM `{$table}` WHERE status = 'completed'" );
281 foreach ( $images as $img ) {
282 if ( file_exists( $img->webp_path ) ) @unlink( $img->webp_path );
283 }
284 $wpdb->query( "TRUNCATE TABLE `{$table}`" );
285 Cache::instance()->clear_cache( true );
286
287 return [ 'success' => true, 'data' => [ 'message' => 'Cleared' ] ];
288 }
289 }
290