PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.11.0
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.11.0
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 / upload-guard.php

upload-guard.php in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder 2.11.0, at includes/classes/images/upload-guard.php

388 lines 11.9 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 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