| 1 |
<?php |
| 2 |
namespace ABlocks\Classes\Images; |
| 3 |
|
| 4 |
if ( ! defined( 'ABSPATH' ) ) { |
| 5 |
exit; |
| 6 |
} |
| 7 |
|
| 8 |
use ABlocks\Helper; |
| 9 |
|
| 10 |
/** |
| 11 |
* Hold uploads to a standard: meaningful filenames, and alt text before use. |
| 12 |
* |
| 13 |
* ## What can and cannot be blocked |
| 14 |
* |
| 15 |
* A **filename** can be rejected outright. `wp_handle_upload_prefilter` runs |
| 16 |
* before the file is written, so `IMG_4821.jpg` never reaches the library and |
| 17 |
* the uploader sees why. |
| 18 |
* |
| 19 |
* **Alt text cannot** be required at upload time, however the request is |
| 20 |
* phrased. Alt text is post meta written after the attachment row exists, and |
| 21 |
* the field someone types it into does not render until the upload has already |
| 22 |
* succeeded — there is no moment during the upload at which it could be |
| 23 |
* present. Anything claiming otherwise is either deleting the attachment |
| 24 |
* afterwards (which loses the file someone just waited to upload) or only |
| 25 |
* pretending. |
| 26 |
* |
| 27 |
* What is genuinely enforceable is stopping the image being *used*: the media |
| 28 |
* modal's insert button stays disabled until alt text is filled in, and posts |
| 29 |
* can be blocked from publishing while they contain images that have none. That |
| 30 |
* reaches the same end — nothing ships without alt text — without throwing away |
| 31 |
* the upload. |
| 32 |
*/ |
| 33 |
class UploadGuard { |
| 34 |
|
| 35 |
/** |
| 36 |
* Filenames that carry no information about the image. |
| 37 |
* |
| 38 |
* These are what cameras, phones and screenshot tools produce. Matched |
| 39 |
* against the name with its extension and separators removed. |
| 40 |
*/ |
| 41 |
const MEANINGLESS = [ |
| 42 |
'img', |
| 43 |
'image', |
| 44 |
'images', |
| 45 |
'dsc', |
| 46 |
'dscn', |
| 47 |
'dscf', |
| 48 |
'pxl', |
| 49 |
'pict', |
| 50 |
'photo', |
| 51 |
'foto', |
| 52 |
'picture', |
| 53 |
'pic', |
| 54 |
'untitled', |
| 55 |
'unnamed', |
| 56 |
'noname', |
| 57 |
'screenshot', |
| 58 |
'screen shot', |
| 59 |
'screen capture', |
| 60 |
'capture', |
| 61 |
'download', |
| 62 |
'downloads', |
| 63 |
'file', |
| 64 |
'copy', |
| 65 |
'final', |
| 66 |
'new', |
| 67 |
'temp', |
| 68 |
'tmp', |
| 69 |
'asset', |
| 70 |
'gopr', |
| 71 |
'dji', |
| 72 |
'mvimg', |
| 73 |
'signal', |
| 74 |
'whatsapp image', |
| 75 |
'photo collage', |
| 76 |
]; |
| 77 |
|
| 78 |
/** |
| 79 |
* Fewest letters a filename must contain to say anything. |
| 80 |
*/ |
| 81 |
const MIN_LETTERS = 4; |
| 82 |
|
| 83 |
public static function init() { |
| 84 |
// Registered in every context, not just admin. These are rules about |
| 85 |
// what may enter the library and what may go live, and content arrives |
| 86 |
// through REST (which is what the block editor uses), WP-CLI, cron and |
| 87 |
// importers as readily as through wp-admin. Gating them on is_admin() |
| 88 |
// would leave the rule enforced only where it happened to be convenient. |
| 89 |
if ( self::require_filename() ) { |
| 90 |
add_filter( 'wp_handle_upload_prefilter', [ __CLASS__, 'check_filename' ] ); |
| 91 |
} |
| 92 |
|
| 93 |
if ( self::require_alt() ) { |
| 94 |
add_filter( 'wp_insert_post_data', [ __CLASS__, 'block_publish_without_alt' ], 10, 2 ); |
| 95 |
|
| 96 |
// The modal gate and its notice are the only admin-only parts. |
| 97 |
if ( is_admin() ) { |
| 98 |
add_action( 'admin_enqueue_scripts', [ __CLASS__, 'enqueue_media_guard' ] ); |
| 99 |
add_action( 'admin_notices', [ __CLASS__, 'publish_blocked_notice' ] ); |
| 100 |
} |
| 101 |
} |
| 102 |
} |
| 103 |
|
| 104 |
/** |
| 105 |
* Is the filename rule switched on? |
| 106 |
* |
| 107 |
* @return bool |
| 108 |
*/ |
| 109 |
public static function require_filename() { |
| 110 |
return (bool) apply_filters( |
| 111 |
'ablocks/images/require_filename', |
| 112 |
(bool) Helper::get_settings( 'perf_image_require_filename', false ) |
| 113 |
); |
| 114 |
} |
| 115 |
|
| 116 |
/** |
| 117 |
* Is the alt text rule switched on? |
| 118 |
* |
| 119 |
* @return bool |
| 120 |
*/ |
| 121 |
public static function require_alt() { |
| 122 |
return (bool) apply_filters( |
| 123 |
'ablocks/images/require_alt', |
| 124 |
(bool) Helper::get_settings( 'perf_image_require_alt', false ) |
| 125 |
); |
| 126 |
} |
| 127 |
|
| 128 |
/** |
| 129 |
* Reject an upload whose filename says nothing about the image. |
| 130 |
* |
| 131 |
* @param array $file Upload array from PHP. |
| 132 |
* @return array |
| 133 |
*/ |
| 134 |
public static function check_filename( $file ) { |
| 135 |
if ( empty( $file['name'] ) || ! empty( $file['error'] ) ) { |
| 136 |
return $file; |
| 137 |
} |
| 138 |
|
| 139 |
// Only images. Blocking a PDF called "invoice-2024.pdf" for being |
| 140 |
// unhelpful would be officious and is not what this is for. |
| 141 |
$type = wp_check_filetype( $file['name'] ); |
| 142 |
if ( empty( $type['type'] ) || 0 !== strpos( $type['type'], 'image/' ) ) { |
| 143 |
return $file; |
| 144 |
} |
| 145 |
|
| 146 |
$reason = self::filename_problem( $file['name'] ); |
| 147 |
if ( null === $reason ) { |
| 148 |
return $file; |
| 149 |
} |
| 150 |
|
| 151 |
$file['error'] = $reason; |
| 152 |
|
| 153 |
return $file; |
| 154 |
} |
| 155 |
|
| 156 |
/** |
| 157 |
* What is wrong with a filename, if anything. |
| 158 |
* |
| 159 |
* @param string $filename Original filename. |
| 160 |
* @return string|null Message for the uploader, or null when acceptable. |
| 161 |
*/ |
| 162 |
public static function filename_problem( $filename ) { |
| 163 |
$name = pathinfo( $filename, PATHINFO_FILENAME ); |
| 164 |
$name = strtolower( trim( (string) $name ) ); |
| 165 |
|
| 166 |
// Separators become spaces so "IMG_4821" and "img-4821" read alike. |
| 167 |
$readable = trim( preg_replace( '/[\-_.]+/', ' ', $name ) ); |
| 168 |
$readable = trim( preg_replace( '/\s+/', ' ', $readable ) ); |
| 169 |
|
| 170 |
if ( '' === $readable ) { |
| 171 |
return self::message( __( 'the file has no name', 'ablocks' ) ); |
| 172 |
} |
| 173 |
|
| 174 |
// Strip trailing counters and dates — "photo 2", "image (3)", |
| 175 |
// "screenshot 2024 05 01" — so the stem is judged rather than whatever |
| 176 |
// the phone or screenshot tool appended. Repeated deliberately: a single |
| 177 |
// pass leaves "screenshot 2024 05", which still reads as a real name. |
| 178 |
$stem = trim( preg_replace( '/(?:[\s(\[]*\d+[\s)\]]*)+$/', '', $readable ) ); |
| 179 |
|
| 180 |
$meaningless = (array) apply_filters( 'ablocks/images/meaningless_filenames', self::MEANINGLESS ); |
| 181 |
if ( in_array( $stem, $meaningless, true ) ) { |
| 182 |
/* translators: %s: the filename that was rejected. */ |
| 183 |
return self::message( sprintf( __( '"%s" is the name your camera or phone gave it', 'ablocks' ), $filename ) ); |
| 184 |
} |
| 185 |
|
| 186 |
// Stripping trailing numbers is not enough on its own: a macOS |
| 187 |
// screenshot is "Screenshot 2024-05-01 at 10.02.33", where the "at" |
| 188 |
// leaves a word behind and the name looks legitimate. So judge what |
| 189 |
// words actually remain once numbers and connecting words are set |
| 190 |
// aside — if all that is left is "screenshot", the name says nothing. |
| 191 |
$filler = (array) apply_filters( 'ablocks/images/filler_words', [ 'at', 'on', 'of', 'am', 'pm', 'copy', 'the', 'a', 'v', 'ver' ] ); |
| 192 |
$remaining = []; |
| 193 |
foreach ( explode( ' ', $readable ) as $word ) { |
| 194 |
$word = trim( $word ); |
| 195 |
if ( '' === $word || is_numeric( $word ) || in_array( $word, $filler, true ) ) { |
| 196 |
continue; |
| 197 |
} |
| 198 |
// Mixed tokens like "20240501" or "img4821" reduce to their letters. |
| 199 |
$letters_only = preg_replace( '/[^a-z]/', '', $word ); |
| 200 |
if ( '' === $letters_only ) { |
| 201 |
continue; |
| 202 |
} |
| 203 |
$remaining[] = $letters_only; |
| 204 |
} |
| 205 |
|
| 206 |
// Checked both ways: every word individually ("img", "photo") and the |
| 207 |
// words rejoined ("screen shot"), because some of these names are only |
| 208 |
// meaningless as a phrase — "screen" alone is fine in |
| 209 |
// "screen-printing-process.jpg". |
| 210 |
$rejoined = implode( ' ', $remaining ); |
| 211 |
if ( |
| 212 |
! empty( $remaining ) && |
| 213 |
( 0 === count( array_diff( $remaining, $meaningless ) ) || in_array( $rejoined, $meaningless, true ) ) |
| 214 |
) { |
| 215 |
/* translators: %s: the filename that was rejected. */ |
| 216 |
return self::message( sprintf( __( '"%s" is the name your camera or phone gave it', 'ablocks' ), $filename ) ); |
| 217 |
} |
| 218 |
|
| 219 |
// Nothing but digits and punctuation. |
| 220 |
if ( ! preg_match( '/[a-z]/', $readable ) ) { |
| 221 |
return self::message( __( 'the name is only numbers', 'ablocks' ) ); |
| 222 |
} |
| 223 |
|
| 224 |
$letters = preg_match_all( '/[a-z]/', $readable ); |
| 225 |
$minimum = (int) apply_filters( 'ablocks/images/min_filename_letters', self::MIN_LETTERS ); |
| 226 |
if ( $letters < $minimum ) { |
| 227 |
return self::message( __( 'the name is too short to describe anything', 'ablocks' ) ); |
| 228 |
} |
| 229 |
|
| 230 |
return null; |
| 231 |
} |
| 232 |
|
| 233 |
/** |
| 234 |
* Build the rejection message. |
| 235 |
* |
| 236 |
* Says what is wrong, what to do, and gives an example — a bare "invalid |
| 237 |
* filename" leaves someone renaming at random until it lets them through. |
| 238 |
* |
| 239 |
* @param string $reason Short reason. |
| 240 |
* @return string |
| 241 |
*/ |
| 242 |
private static function message( $reason ) { |
| 243 |
return sprintf( |
| 244 |
/* translators: %s: short explanation of what is wrong with the filename. */ |
| 245 |
__( 'This image was not uploaded because %s. Rename the file to describe what it shows — for example "red-running-shoes-side-view.jpg" instead of "IMG_4821.jpg". Descriptive filenames help search engines and anyone using a screen reader.', 'ablocks' ), |
| 246 |
$reason |
| 247 |
); |
| 248 |
} |
| 249 |
|
| 250 |
/** |
| 251 |
* Load the media-library gate. |
| 252 |
* |
| 253 |
* @param string $hook Current admin page. |
| 254 |
*/ |
| 255 |
public static function enqueue_media_guard( $hook ) { |
| 256 |
// Loaded wherever the media modal can be opened. did_action() catches |
| 257 |
// anything that has already called wp_enqueue_media(); the list covers |
| 258 |
// the screens that call it later than this hook runs — including the |
| 259 |
// site editor, which is where images get inserted on a block theme. |
| 260 |
$screens = (array) apply_filters( |
| 261 |
'ablocks/images/alt_guard_screens', |
| 262 |
[ 'post.php', 'post-new.php', 'upload.php', 'site-editor.php', 'widgets.php', 'customize.php' ] |
| 263 |
); |
| 264 |
|
| 265 |
if ( ! did_action( 'wp_enqueue_media' ) && ! in_array( $hook, $screens, true ) ) { |
| 266 |
return; |
| 267 |
} |
| 268 |
|
| 269 |
wp_enqueue_script( |
| 270 |
'ablocks-media-alt-guard', |
| 271 |
ABLOCKS_ASSETS_URL . 'js/media-alt-guard.js', |
| 272 |
[ 'media-views' ], |
| 273 |
ABLOCKS_VERSION, |
| 274 |
true |
| 275 |
); |
| 276 |
|
| 277 |
wp_localize_script( |
| 278 |
'ablocks-media-alt-guard', |
| 279 |
'aBlocksAltGuard', |
| 280 |
[ |
| 281 |
'message' => __( 'Add alt text before using this image.', 'ablocks' ), |
| 282 |
'hint' => __( 'Describe what the image shows. Leave it empty only if the image is purely decorative.', 'ablocks' ), |
| 283 |
] |
| 284 |
); |
| 285 |
} |
| 286 |
|
| 287 |
/** |
| 288 |
* Refuse to publish a post that uses images with no alt text. |
| 289 |
* |
| 290 |
* This is the part that actually enforces the rule. The media modal gate |
| 291 |
* covers the normal path, but it is client side and only guards the modal — |
| 292 |
* a block pasted in, an imported page or a REST call would sail past it. |
| 293 |
* Checking at save time is the backstop that cannot be walked around. |
| 294 |
* |
| 295 |
* The post is demoted to draft rather than rejected, so the writing is never |
| 296 |
* lost; a notice explains why. |
| 297 |
* |
| 298 |
* @param array $data Sanitised post data. |
| 299 |
* @param array $postarr Raw post data. |
| 300 |
* @return array |
| 301 |
*/ |
| 302 |
public static function block_publish_without_alt( $data, $postarr ) { |
| 303 |
if ( 'publish' !== $data['post_status'] ) { |
| 304 |
return $data; |
| 305 |
} |
| 306 |
if ( ! empty( $data['post_type'] ) && ! is_post_type_viewable( $data['post_type'] ) ) { |
| 307 |
return $data; |
| 308 |
} |
| 309 |
if ( wp_is_post_revision( $postarr['ID'] ?? 0 ) || wp_is_post_autosave( $postarr['ID'] ?? 0 ) ) { |
| 310 |
return $data; |
| 311 |
} |
| 312 |
|
| 313 |
$missing = self::images_without_alt( (string) $data['post_content'] ); |
| 314 |
if ( empty( $missing ) ) { |
| 315 |
return $data; |
| 316 |
} |
| 317 |
|
| 318 |
$data['post_status'] = 'draft'; |
| 319 |
|
| 320 |
set_transient( |
| 321 |
'ablocks_alt_block_' . get_current_user_id(), |
| 322 |
$missing, |
| 323 |
60 |
| 324 |
); |
| 325 |
|
| 326 |
return $data; |
| 327 |
} |
| 328 |
|
| 329 |
/** |
| 330 |
* Attachment ids referenced in content that have no alt text. |
| 331 |
* |
| 332 |
* @param string $content Post content. |
| 333 |
* @return int[] |
| 334 |
*/ |
| 335 |
public static function images_without_alt( $content ) { |
| 336 |
if ( false === strpos( $content, 'wp-image-' ) ) { |
| 337 |
return []; |
| 338 |
} |
| 339 |
|
| 340 |
if ( ! preg_match_all( '/wp-image-(\d+)/', $content, $matches ) ) { |
| 341 |
return []; |
| 342 |
} |
| 343 |
|
| 344 |
$missing = []; |
| 345 |
foreach ( array_unique( $matches[1] ) as $id ) { |
| 346 |
$id = (int) $id; |
| 347 |
if ( ! $id ) { |
| 348 |
continue; |
| 349 |
} |
| 350 |
$alt = get_post_meta( $id, '_wp_attachment_image_alt', true ); |
| 351 |
if ( '' === trim( (string) $alt ) ) { |
| 352 |
$missing[] = $id; |
| 353 |
} |
| 354 |
} |
| 355 |
|
| 356 |
return $missing; |
| 357 |
} |
| 358 |
|
| 359 |
/** |
| 360 |
* Explain a blocked publish. |
| 361 |
*/ |
| 362 |
public static function publish_blocked_notice() { |
| 363 |
$key = 'ablocks_alt_block_' . get_current_user_id(); |
| 364 |
$missing = get_transient( $key ); |
| 365 |
|
| 366 |
if ( empty( $missing ) || ! is_array( $missing ) ) { |
| 367 |
return; |
| 368 |
} |
| 369 |
delete_transient( $key ); |
| 370 |
|
| 371 |
$links = []; |
| 372 |
foreach ( array_slice( $missing, 0, 10 ) as $id ) { |
| 373 |
$title = get_the_title( $id ); |
| 374 |
$edit = get_edit_post_link( $id ); |
| 375 |
$label = $title ? $title : '#' . $id; |
| 376 |
$links[] = $edit |
| 377 |
? '<a href="' . esc_url( $edit ) . '">' . esc_html( $label ) . '</a>' |
| 378 |
: esc_html( $label ); |
| 379 |
} |
| 380 |
|
| 381 |
echo '<div class="notice notice-error"><p><strong>'; |
| 382 |
esc_html_e( 'Saved as a draft: some images have no alt text.', 'ablocks' ); |
| 383 |
echo '</strong></p><p>'; |
| 384 |
esc_html_e( 'Your site requires every image to describe itself before a post goes live. Add alt text to the images below, then publish again.', 'ablocks' ); |
| 385 |
echo '</p><p>' . wp_kses_post( implode( ', ', $links ) ) . '</p></div>'; |
| 386 |
} |
| 387 |
} |
| 388 |
|