| 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 |
|