PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.11.1
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.11.1
2.13.0 2.13.1 2.12.0 2.11.1 2.11.0 2.10.0 2.9.0 2.7.4 2.7.5 2.7.6 2.7.7 2.8.0 2.8.1 2.9.1 trunk 1.0 1.0-beta1 1.0-beta2 1.0-beta3 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 All 80 releases
ablocks / includes / classes / images / unused-scanner.php

unused-scanner.php in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder 2.11.1, at includes/classes/images/unused-scanner.php

236 lines 6.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace ABlocks\Classes\Images;
3
4 if ( ! defined( 'ABSPATH' ) ) {
5 exit;
6 }
7
8 /**
9 * Find attachments that appear to be unreferenced.
10 *
11 * ## Read this before trusting the result
12 *
13 * "Unused" cannot be determined reliably in WordPress. An image can be
14 * referenced from post content, post meta, options, theme mods, widgets, menus,
15 * customizer settings, page-builder JSON, a CSS file, another plugin's custom
16 * table, or an external site hot-linking it. A scanner sees some of those and
17 * cannot see the rest.
18 *
19 * So this deliberately answers a narrower question — "did we find any evidence
20 * this is used?" — and treats absence of evidence as a *suspicion*, never as
21 * proof. Everything it produces is a candidate for human review, which is why
22 * removal quarantines rather than deletes. See {@see Quarantine}.
23 *
24 * Guards that keep false positives survivable:
25 *
26 * - Attachments newer than a grace period are never listed. A freshly uploaded
27 * image is routinely unreferenced for the minutes between uploading it and
28 * using it, and that window is exactly when someone runs a cleanup.
29 * - Featured images, site icon, custom logo and header are checked explicitly.
30 * - Both the full URL and the bare filename are searched, so a resized variant
31 * or a relative reference still counts as a use.
32 */
33 class UnusedScanner {
34
35 /**
36 * Attachments younger than this are never considered unused.
37 */
38 const GRACE_DAYS = 30;
39
40 /**
41 * Scan a batch of attachments.
42 *
43 * @param int $limit Attachments to examine.
44 * @param int $offset Where to resume from.
45 * @return array{items:array, scanned:int, done:bool}
46 */
47 public static function scan( $limit = 50, $offset = 0 ) {
48 $limit = max( 1, min( 200, (int) $limit ) );
49 $offset = max( 0, (int) $offset );
50
51 $query = new \WP_Query(
52 [
53 'post_type' => 'attachment',
54 'post_status' => 'inherit',
55 'post_mime_type' => 'image',
56 'posts_per_page' => $limit,
57 'offset' => $offset,
58 'orderby' => 'ID',
59 'order' => 'ASC',
60 'fields' => 'ids',
61 'no_found_rows' => false,
62 ]
63 );
64
65 $grace = time() - ( (int) apply_filters( 'ablocks/images/unused_grace_days', self::GRACE_DAYS ) * DAY_IN_SECONDS );
66 $items = [];
67
68 foreach ( $query->posts as $id ) {
69 $id = (int) $id;
70
71 if ( get_post_time( 'U', true, $id ) > $grace ) {
72 continue;
73 }
74
75 $evidence = self::find_usage( $id );
76 if ( ! empty( $evidence ) ) {
77 continue;
78 }
79
80 $items[] = [
81 'id' => $id,
82 'title' => get_the_title( $id ),
83 'url' => wp_get_attachment_url( $id ),
84 'thumb' => wp_get_attachment_image_url( $id, 'thumbnail' ),
85 'bytes' => self::size_of( $id ),
86 'date' => get_the_date( 'Y-m-d', $id ),
87 ];
88 }//end foreach
89
90 return [
91 'items' => $items,
92 'scanned' => $offset + count( $query->posts ),
93 'total' => (int) $query->found_posts,
94 'done' => count( $query->posts ) < $limit,
95 ];
96 }
97
98 /**
99 * Look for any evidence that an attachment is in use.
100 *
101 * Returns as soon as it finds something: the caller only needs to know
102 * whether evidence exists, and stopping early keeps the scan cheap.
103 *
104 * @param int $attachment_id Attachment ID.
105 * @return string[] Reasons it appears used; empty when none were found.
106 */
107 public static function find_usage( $attachment_id ) {
108 global $wpdb;
109
110 $attachment_id = (int) $attachment_id;
111 $found = [];
112
113 // Attached to a post. Cheap, and the most common real use.
114 $parent = (int) get_post_field( 'post_parent', $attachment_id );
115 if ( $parent && 'attachment' !== get_post_type( $parent ) ) {
116 $found[] = 'attached';
117 return $found;
118 }
119
120 // Featured image.
121 $thumb_for = $wpdb->get_var(
122 $wpdb->prepare(
123 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_thumbnail_id' AND meta_value = %d LIMIT 1",
124 $attachment_id
125 )
126 );
127 if ( $thumb_for ) {
128 $found[] = 'featured-image';
129 return $found;
130 }
131
132 // Site identity: logo, icon, header, background.
133 foreach ( [ 'site_icon', 'site_logo' ] as $option ) {
134 if ( (int) get_option( $option ) === $attachment_id ) {
135 $found[] = $option;
136 return $found;
137 }
138 }
139 foreach ( [ 'custom_logo', 'header_image_data' ] as $mod ) {
140 $value = get_theme_mod( $mod );
141 if ( is_object( $value ) && isset( $value->attachment_id ) && (int) $value->attachment_id === $attachment_id ) {
142 $found[] = 'theme-' . $mod;
143 return $found;
144 }
145 if ( is_numeric( $value ) && (int) $value === $attachment_id ) {
146 $found[] = 'theme-' . $mod;
147 return $found;
148 }
149 }
150
151 $url = wp_get_attachment_url( $attachment_id );
152 $file = get_post_meta( $attachment_id, '_wp_attached_file', true );
153
154 $needles = array_values(
155 array_unique(
156 array_filter(
157 [
158 $url,
159 $file ? basename( $file ) : '',
160 'wp-image-' . $attachment_id,
161 ]
162 )
163 )
164 );
165
166 foreach ( $needles as $needle ) {
167 $like = '%' . $wpdb->esc_like( $needle ) . '%';
168
169 // Post content and excerpts, including page-builder markup, which is
170 // stored as post content or as JSON in meta.
171 $in_posts = $wpdb->get_var(
172 $wpdb->prepare(
173 "SELECT ID FROM {$wpdb->posts}
174 WHERE post_status NOT IN ('trash','auto-draft')
175 AND ( post_content LIKE %s OR post_excerpt LIKE %s )
176 LIMIT 1",
177 $like,
178 $like
179 )
180 );
181 if ( $in_posts ) {
182 $found[] = 'in-content:' . (int) $in_posts;
183 return $found;
184 }
185
186 // Meta covers ACF fields, builder payloads and most plugin storage.
187 $in_meta = $wpdb->get_var(
188 $wpdb->prepare(
189 "SELECT post_id FROM {$wpdb->postmeta}
190 WHERE meta_value LIKE %s AND post_id <> %d
191 LIMIT 1",
192 $like,
193 $attachment_id
194 )
195 );
196 if ( $in_meta ) {
197 $found[] = 'in-meta:' . (int) $in_meta;
198 return $found;
199 }
200
201 // Options catch widgets, theme mods, and plugin settings.
202 $in_options = $wpdb->get_var(
203 $wpdb->prepare(
204 "SELECT option_name FROM {$wpdb->options} WHERE option_value LIKE %s LIMIT 1",
205 $like
206 )
207 );
208 if ( $in_options ) {
209 $found[] = 'in-option:' . sanitize_key( $in_options );
210 return $found;
211 }
212 }//end foreach
213
214 // A last chance for anything with its own storage: a plugin that keeps
215 // media references in a custom table can answer here rather than having
216 // its images quarantined.
217 return (array) apply_filters( 'ablocks/images/usage_evidence', $found, $attachment_id );
218 }
219
220 /**
221 * Total bytes an attachment occupies, including generated sizes.
222 *
223 * @param int $attachment_id Attachment ID.
224 * @return int
225 */
226 public static function size_of( $attachment_id ) {
227 $total = 0;
228 foreach ( Compressor::files_for( $attachment_id ) as $path ) {
229 if ( file_exists( $path ) ) {
230 $total += (int) filesize( $path );
231 }
232 }
233 return $total;
234 }
235 }
236