PluginProbe
WP Attachments – Smarter File Management & Download Lists / 6.1
WP Attachments – Smarter File Management & Download Lists v6.1
6.1 6.0.2 6.0.3 trunk 2.0 3 3.0.1 3.0.2 3.0.3 3.1 3.1.1 3.1.2 3.1.3 3.1.4 3.2 3.2.1 3.2.2 3.2.3 3.2.4 3.3 3.4 3.5 3.5.1 3.5.2 3.5.3 All 53 releases
wp-attachments / wp-attachments.php

wp-attachments.php in WP Attachments – Smarter File Management & Download Lists 6.1, at wp-attachments.php

919 lines 32.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /*
3 Plugin Name: WP Attachments – Smarter File Management & Download Lists
4 Plugin URI: https://wordpress.org/plugins/wp-attachments
5 Description: Powerful solution to manage and show your WordPress media in posts and pages
6 Author: Marco Milesi
7 Author URI: https://www.marcomilesi.com
8 Version: 6.1
9 Requires at least: 5.0
10 Requires PHP: 7.4
11 License: GPLv2 or later
12 License URI: http://www.gnu.org/licenses/gpl-2.0.html
13 Text Domain: wp-attachments
14 */
15
16 // Keep in sync with the Version header above: it versions the CSS and JS
17 // URLs, so a stale value keeps browsers on the old files after an update.
18 define( 'WPATT_VERSION', '6.1' );
19
20 require_once( plugin_dir_path(__FILE__) . 'inc/attach_unattach_reattach.php' );
21 require_once( plugin_dir_path(__FILE__) . 'inc/file-types.php' );
22
23 add_action('init', function () {
24 load_plugin_textdomain( 'wp-attachments' );
25 });
26
27 // Frontend only: enqueueing on 'init' also loaded it in wp-admin and on the login screen.
28 add_action('wp_enqueue_scripts', 'wpatt_enqueue_list_styles');
29
30 // Settings screen: the template previews use the real list styles.
31 add_action('admin_enqueue_scripts', function ($hook) {
32 if ('settings_page_wpatt-option-page' === $hook) {
33 wpatt_enqueue_list_styles();
34 }
35 });
36
37 /**
38 * Stylesheets of the attachments list: the shared one, then the icon pack.
39 *
40 * Also used by the settings screen, so its previews look like the site.
41 */
42 function wpatt_enqueue_list_styles() {
43 $base = plugin_dir_url(__FILE__) . 'styles/';
44
45 wp_enqueue_style('wpa-common', $base . 'common.css', array(), WPATT_VERSION);
46 wp_enqueue_style('wpa-css', $base . wpatt_icon_pack() . '/wpa.css', array('wpa-common'), WPATT_VERSION);
47 }
48
49 /** Modern icon pack: inline SVG icons instead of PNG backgrounds. */
50 define( 'WPATT_PACK_MODERN', 5 );
51
52 /** Card template: always uses the Modern icons, whatever the pack. */
53 define( 'WPATT_TEMPLATE_CARD', 4 );
54
55 /**
56 * Selected icon pack, 0-5. Modern is the default for new installs.
57 *
58 * @return int
59 */
60 function wpatt_icon_pack() {
61 // Fallback 0, not Modern: sites updated from an older version may never
62 // have saved this option, and must keep the pack they have always shown.
63 // New installs get Modern from wpa_register_initial_settings().
64 $pack = (int) get_option( 'wpa_ict', 0 );
65
66 return ( $pack >= 0 && $pack <= WPATT_PACK_MODERN ) ? $pack : 0;
67 }
68
69 /**
70 * Selected display template, 0-4.
71 *
72 * @return int
73 */
74 function wpatt_template_id() {
75 $template = (int) get_option( 'wpa_template', 0 );
76
77 return ( $template >= 0 && $template <= WPATT_TEMPLATE_CARD ) ? $template : 0;
78 }
79
80 /**
81 * How the SVG icons are coloured: 'type' (a colour per file type), 'theme'
82 * (the text colour of the theme) or 'custom' (one colour chosen in the settings).
83 *
84 * @return string
85 */
86 function wpatt_icons_color_mode() {
87 $mode = get_option( 'wpatt_icons_color', 'type' );
88
89 return in_array( $mode, array( 'type', 'theme', 'custom' ), true ) ? $mode : 'type';
90 }
91
92 /**
93 * The custom icon colour, as a hex value.
94 *
95 * @return string
96 */
97 function wpatt_icons_custom_color() {
98 $color = sanitize_hex_color( (string) get_option( 'wpatt_icons_custom_color', '#1c5f96' ) );
99
100 return $color ? $color : '#1c5f96';
101 }
102
103 /**
104 * Class and inline style that colour the SVG icons inside an element.
105 *
106 * The custom colour travels as a CSS variable on the wrapper, so a single
107 * rule in common.css covers it and nothing is printed in the page head.
108 *
109 * @return array { @type string $class Class to append, with a leading space. @type string $style Inline style. }
110 */
111 function wpatt_icons_color_attrs() {
112 switch ( wpatt_icons_color_mode() ) {
113 case 'custom':
114 return array( 'class' => ' wpa-icons-custom', 'style' => '--wpa-custom:' . wpatt_icons_custom_color() );
115 case 'theme':
116 return array( 'class' => '', 'style' => '' );
117 default:
118 return array( 'class' => ' wpa-icons-color', 'style' => '' );
119 }
120 }
121
122 /**
123 * Handle ?download=ID hits: count the click, then redirect to the real file.
124 *
125 * Runs on 'template_redirect' because conditional tags such as is_attachment()
126 * are not reliable before the main query has run.
127 */
128 add_action('template_redirect', function () {
129 if ( ! get_option('wpatt_counter') || ! isset($_GET['download']) ) {
130 return;
131 }
132
133 if ( is_attachment() ) {
134 return;
135 }
136
137 $download_id = absint( wp_unslash($_GET['download']) );
138 if ( ! $download_id || get_post_type($download_id) !== 'attachment' ) {
139 return;
140 }
141
142 if ( ! wpa_can_download( $download_id ) ) {
143 return;
144 }
145
146 $excludelogged = true;
147 if ( get_option('wpatt_excludelogged_counter') ) {
148 $excludelogged = !is_user_logged_in();
149 }
150
151 if ( $excludelogged && wpa_is_countable_request() && wpa_is_valid_download($download_id) ) {
152 $newcounter = intval(get_post_meta($download_id, "wpa-download", true));
153 $newcounter++;
154 update_post_meta($download_id, 'wpa-download', $newcounter );
155 }
156
157 // wp_redirect(), not wp_safe_redirect(): the URL comes from the database,
158 // not from the request, and media offloaded to a CDN or S3 lives on
159 // another host -- which wp_safe_redirect() turned into a jump to wp-admin.
160 $redirect_url = wp_get_attachment_url($download_id);
161 if ($redirect_url) {
162 wp_redirect(esc_url_raw($redirect_url));
163 exit;
164 }
165 });
166
167 /**
168 * May the current visitor follow a ?download= link to this attachment?
169 *
170 * The redirect reveals the file URL, so it follows the visibility of the
171 * content the file is attached to: an ID must not be enough to dig out the
172 * files of private, draft or password protected posts.
173 *
174 * @param int $attachment_id Attachment ID.
175 * @return bool
176 */
177 function wpa_can_download( $attachment_id ) {
178 $attachment = get_post( $attachment_id );
179 if ( ! $attachment ) {
180 return false;
181 }
182
183 $parent_id = (int) $attachment->post_parent;
184 $allowed = true;
185
186 if ( $parent_id ) {
187 // WooCommerce orders, which under HPOS are not posts: customers may
188 // follow the links shown on their own order.
189 $order = function_exists( 'wc_get_order' ) ? wc_get_order( $parent_id ) : false;
190
191 if ( $order ) {
192 $allowed = current_user_can( 'view_order', $parent_id ) || current_user_can( 'edit_shop_orders' );
193 } else {
194 $parent = get_post( $parent_id );
195
196 if ( ! $parent ) {
197 $allowed = true; // Orphaned: behaves like an unattached file.
198 } elseif ( post_password_required( $parent ) ) {
199 $allowed = false;
200 } else {
201 $public = function_exists( 'is_post_publicly_viewable' )
202 ? is_post_publicly_viewable( $parent )
203 : ( 'publish' === get_post_status( $parent ) );
204 $allowed = $public || current_user_can( 'read_post', $parent_id );
205 }
206 }
207 }
208
209 /**
210 * Filter whether the current visitor may download an attachment through the counter link.
211 *
212 * @param bool $allowed Whether the download is allowed.
213 * @param int $attachment_id Attachment ID.
214 */
215 return (bool) apply_filters( 'wpatt_can_download', $allowed, $attachment_id );
216 }
217
218 /**
219 * Send the user back to the editor after deleting an attachment from the metabox.
220 *
221 * Core redirects to the Media Library, so the destination is swapped through
222 * the wp_redirect filter. It must not redirect and exit from 'deleted_post':
223 * wp_delete_attachment() removes the files from disk only after that hook,
224 * so exiting there left the original and every thumbnail publicly reachable.
225 */
226 add_action('deleted_post', function($post_id, $post) {
227 if ( ! $post || $post->post_type !== 'attachment' ) {
228 return;
229 }
230
231 if ( ! isset($_REQUEST['forcedelete']) || $_REQUEST['forcedelete'] !== 'true' ) {
232 return;
233 }
234
235 $referer = wp_get_referer();
236 if ( ! $referer || strpos($referer, 'post.php') === false ) {
237 return;
238 }
239
240 add_filter('wp_redirect', function() use ($referer) {
241 return remove_query_arg('message', $referer);
242 }, 99);
243 }, 10, 2);
244
245 add_action('admin_init', function() {
246 require_once(plugin_dir_path(__FILE__) . 'inc/settings.php');
247 require_once(plugin_dir_path(__FILE__) . 'inc/meta-box.php');
248 require_once(plugin_dir_path(__FILE__) . 'inc/post-columns.php');
249 if (get_option('wpatt_counter')) { require_once(plugin_dir_path(__FILE__) . 'inc/counter.php'); }
250
251 // get_plugin_data() re-read and parsed this file on every admin request.
252 update_option( 'wpa_version_number', WPATT_VERSION );
253 } );
254
255 /**
256 * Format a byte count for display.
257 *
258 * Thin wrapper around core size_format(); kept as a function for
259 * back-compat with themes that may already call it.
260 *
261 * @param int $a_bytes Size in bytes.
262 * @param int $decimals Decimal places to show.
263 * @return string
264 */
265 function wpatt_format_bytes($a_bytes, $decimals = 0) {
266 $a_bytes = (int) $a_bytes;
267
268 // size_format() would render this as '0.0 B' when decimals are requested.
269 if ( $a_bytes <= 0 ) {
270 return size_format( 0 );
271 }
272
273 $formatted = size_format( $a_bytes, $decimals );
274
275 return ( false === $formatted ) ? size_format( 0 ) : $formatted;
276 }
277
278 /**
279 * Size of an attachment in bytes.
280 *
281 * Read from the attachment metadata first (WordPress 6.0+ stores it there),
282 * so it also works when the file is not on the local disk, as with media
283 * offloaded to a CDN or S3. Falls back to the local file.
284 *
285 * @param int $attachment_id Attachment ID.
286 * @return int|false Size in bytes, or false when it cannot be determined.
287 */
288 function wpatt_get_attachment_filesize( $attachment_id ) {
289 $meta = wp_get_attachment_metadata( $attachment_id );
290 if ( is_array( $meta ) && ! empty( $meta['filesize'] ) ) {
291 return (int) $meta['filesize'];
292 }
293
294 $path = get_attached_file( $attachment_id );
295 if ( $path && file_exists( $path ) ) {
296 return (int) filesize( $path );
297 }
298
299 return false;
300 }
301
302 add_action('woocommerce_order_details_after_customer_details', function( $order ) {
303 // "My Account" > "Order View". Under HPOS an order is not a post, so it has
304 // no ->ID and must not be passed through the the_content filter.
305 if ( ! is_object( $order ) || ! method_exists( $order, 'get_id' ) ) {
306 return;
307 }
308 if ( ! is_wc_endpoint_url( 'view-order' ) ) {
309 return;
310 }
311
312 echo wpatt_get_attachments_html( $order->get_id() );
313 }, 10, 1 );
314
315 /**
316 * Tell WooCommerce this plugin is safe with High-Performance Order Storage.
317 * Without it WooCommerce lists the plugin as incompatible.
318 */
319 add_action('before_woocommerce_init', function() {
320 if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
321 \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
322 }
323 });
324
325
326 add_filter('the_content', 'wpatt_content_filter');
327
328 function wpatt_content_filter( $content, $post = null ) {
329 if ( !$post ) {
330 global $post;
331 }
332
333 if ( !is_object($post) || empty($post->ID) || post_password_required() || ( get_option('wpatt_option_restrictload') && !is_single() && !is_page() ) ) {
334 return $content;
335 }
336
337 if ( ! wpatt_is_frontend_enabled( $post->post_type ) || ! wpatt_is_display_enabled( $post ) ) {
338 return $content;
339 }
340
341 if ( ! wpatt_should_render( $post ) ) {
342 return $content;
343 }
344
345 return $content . wpatt_get_attachments_html( $post->ID );
346 }
347
348 /**
349 * Is this the_content call one the list belongs to?
350 *
351 * the_content also runs for feeds, REST responses, automatic excerpts and,
352 * in block themes, for every post inside Query Loop blocks such as the
353 * "More posts" section under a single post -- each of which would get its
354 * own copy of the list.
355 *
356 * @param WP_Post $post Post being rendered.
357 * @return bool
358 */
359 function wpatt_should_render( $post ) {
360 $render = true;
361
362 if ( is_feed() || doing_filter( 'get_the_excerpt' ) ) {
363 $render = false;
364 } elseif ( function_exists( 'wp_is_serving_rest_request' ) ? wp_is_serving_rest_request() : ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
365 $render = false;
366 } elseif ( is_singular() && (int) get_queried_object_id() !== (int) $post->ID ) {
367 // On a single post or page, only that content gets the list.
368 $render = false;
369 }
370
371 /**
372 * Filter whether the attachments list is appended to this content.
373 *
374 * @param bool $render Whether the list is shown.
375 * @param WP_Post $post Post being rendered.
376 */
377 return (bool) apply_filters( 'wpatt_should_render', $render, $post );
378 }
379
380 /**
381 * Is the frontend list switched on for this post type?
382 *
383 * Until "Enable Frontend" is first saved it follows "Enable Metabox", which
384 * is what used to hide the list before the two were separate settings.
385 *
386 * @param string $post_type Post type name.
387 * @return bool
388 */
389 function wpatt_is_frontend_enabled( $post_type ) {
390 $metabox = get_option( 'wpatt_enable_metabox_' . $post_type, '1' );
391
392 return get_option( 'wpatt_enable_frontend_' . $post_type, $metabox ) === '1';
393 }
394
395 /**
396 * Has this post not been opted out of the list?
397 *
398 * The list shows by default; the metabox toggle is the exception that turns
399 * it off, stored as 'wpa_off' = '1'.
400 *
401 * @param WP_Post|int $post Post object or ID.
402 * @return bool
403 */
404 function wpatt_is_display_enabled( $post ) {
405 $post = get_post( $post );
406 if ( ! $post ) {
407 return false;
408 }
409
410 return '1' !== (string) get_post_meta( $post->ID, 'wpa_off', true );
411 }
412
413 /**
414 * Build the attachments list for a given parent ID.
415 *
416 * Kept separate from the the_content filter so callers that are not posts --
417 * WooCommerce orders under HPOS, for instance -- can render the same list
418 * without faking a WP_Post object.
419 *
420 * @param int $parent_id Parent object ID.
421 * @return string HTML, or an empty string when there is nothing to show.
422 */
423 function wpatt_get_attachments_html( $parent_id ) {
424 $parent_id = absint( $parent_id );
425 if ( ! $parent_id ) {
426 return '';
427 }
428
429 $content = '';
430
431 $orderby = sanitize_text_field(get_query_var('orderby'));
432 $order = 'ASC';
433 if ($orderby === 'date') {
434 $order = 'DESC';
435 } elseif ($orderby !== 'title') {
436 $orderby = 'menu_order';
437 }
438
439 $attachments = get_posts(array(
440 'post_type' => 'attachment',
441 'orderby' => $orderby,
442 'order' => $order,
443 'posts_per_page' => -1,
444 'post_status' => 'any',
445 'post_parent' => $parent_id
446 ));
447
448 $toShow = 0;
449
450 $orderby_html = '';
451 if ( get_option('wpatt_show_orderby') != 0 && count($attachments) > 1 ) {
452 $sort_links = array(
453 'menu_order' => array( esc_html__( 'Default', 'wp-attachments' ), remove_query_arg( 'orderby' ) ),
454 'date' => array( esc_html__( 'Date', 'wp-attachments' ), add_query_arg( 'orderby', 'date' ) ),
455 'title' => array( esc_html__( 'Name', 'wp-attachments' ), add_query_arg( 'orderby', 'title' ) ),
456 );
457
458 $sort_items = '';
459 foreach ( $sort_links as $sort_key => $sort_link ) {
460 $is_current = ( $orderby === $sort_key );
461 $sort_items .= '<a class="wpa-orderby-link' . ( $is_current ? ' is-current' : '' ) . '"'
462 . ' href="' . esc_url( $sort_link[1] ) . '"'
463 . ( $is_current ? ' aria-current="true"' : '' ) . '>'
464 . $sort_link[0] . '</a>';
465 }
466
467 $orderby_html = '<span class="wpa-orderby"><span class="wpa-orderby-label">'
468 . esc_html__( 'Sort by:', 'wp-attachments' ) . '</span>' . $sort_items . '</span>';
469 }
470
471 if ($attachments) {
472 // The default applies only until the settings are first saved; an
473 // empty header saved on purpose hides the heading.
474 $heading_text = trim( (string) get_option( 'wpatt_option_localization', __( 'Attachments', 'wp-attachments' ) ) );
475 $heading_tag = wpatt_heading_tag();
476 $heading_html = ( '' !== $heading_text )
477 ? '<' . $heading_tag . ' class="wpa-attachments-title">' . esc_html( $heading_text ) . '</' . $heading_tag . '>'
478 : '';
479
480 $head_html = ( '' !== $heading_html || '' !== $orderby_html )
481 ? '<div class="wpa-attachments-head">' . $heading_html . $orderby_html . '</div>'
482 : '';
483
484 $template_id = wpatt_template_id();
485 $template = wpatt_get_template_string( $template_id );
486 $items = '';
487
488 foreach ($attachments as $attachment) {
489 $include_images = get_option('wpatt_option_includeimages');
490 if ($include_images !== '1' && wp_attachment_is_image( $attachment->ID )) {
491 continue;
492 }
493
494 if ( !apply_filters( 'wpatt_accepted_formats', sanitize_title($attachment->post_mime_type) ) ) {
495 continue;
496 }
497
498 $items .= wpatt_render_list_item( $template_id, $template, wpatt_get_entry_data( $attachment ) );
499 $toShow = 1;
500 }
501
502 if ( $toShow ) {
503 $content .= apply_filters( 'wpatt_list_html', wpatt_wrap_list( $items, $head_html, $template_id ) );
504 }
505 }
506 return $content;
507 }
508
509 /**
510 * Markup of a display template, with its %TAG% placeholders.
511 *
512 * @param int $template_id 0 simple, 1 with date, 2 detailed, 3 custom, 4 Modern Card.
513 * @param array|null $card Modern Card options, from wpatt_get_card_options().
514 * @return string
515 */
516 function wpatt_get_template_string( $template_id, $card = null ) {
517 switch ( (int) $template_id ) {
518 case 1:
519 return '<a href="%URL%">%TITLE%</a> <small>(%SIZE%)</small> <span class="wpa-attachment-date">%DATE%</span>';
520 case 2:
521 return '<a href="%URL%">%TITLE%</a> <small>&bull; %SIZE% &bull; %DOWNLOADS% click</small> <span class="wpa-attachment-date">%DATE%</span><br><small>%CAPTION%</small>';
522 case 3:
523 // Legacy templates were stored HTML-encoded, so they still need
524 // decoding -- but kses must run again afterwards, otherwise an
525 // encoded <script> smuggled past the save-time wp_kses_post()
526 // would be decoded straight into the page.
527 return wp_kses_post( html_entity_decode( (string) get_option('wpa_template_custom') ) );
528 case WPATT_TEMPLATE_CARD:
529 return wpatt_get_card_template( is_array( $card ) ? $card : wpatt_get_card_options() );
530 default:
531 return '<a href="%URL%">%TITLE%</a> <small>(%SIZE%)</small>';
532 }
533 }
534
535 /**
536 * Markup of the Modern Card template for the given options.
537 *
538 * Each detail sits in its own <span> with no whitespace around the tag, so
539 * the CSS can hide the empty ones (:empty) and put a separator only between
540 * the details that are actually shown.
541 *
542 * @param array $card Options from wpatt_get_card_options().
543 * @return string
544 */
545 function wpatt_get_card_template( array $card ) {
546 $meta = '';
547 if ( $card['ext'] ) {
548 $meta .= '<span>%EXT%</span>';
549 }
550 if ( $card['size'] ) {
551 $meta .= '<span>%SIZE%</span>';
552 }
553 if ( $card['date'] ) {
554 $meta .= '<span>%DATE%</span>';
555 }
556 if ( $card['downloads'] && get_option( 'wpatt_counter' ) ) {
557 /* translators: %s: number of downloads. */
558 $meta .= '<span>' . sprintf( esc_html__( 'Downloads: %s', 'wp-attachments' ), '%DOWNLOADS%' ) . '</span>';
559 }
560
561 // href first: the "open in a new tab" option matches '<a href'.
562 return '<span class="wpa-card-icon">%ICON%</span>'
563 . '<span class="wpa-card-body"><a href="%URL%" class="wpa-card-title">%TITLE%</a>'
564 . ( '' !== $meta ? '<span class="wpa-card-meta">' . $meta . '</span>' : '' )
565 . ( $card['caption'] ? '<span class="wpa-card-caption">%CAPTION%</span>' : '' )
566 . '</span>';
567 }
568
569 /**
570 * Values for the template tags of one attachment, not yet escaped.
571 *
572 * @param WP_Post $attachment Attachment.
573 * @return array
574 */
575 function wpatt_get_entry_data( $attachment ) {
576 $bytes = wpatt_get_attachment_filesize( $attachment->ID );
577
578 return array(
579 'url' => get_option('wpatt_counter')
580 ? add_query_arg( 'download', $attachment->ID, get_permalink() )
581 : wp_get_attachment_url( $attachment->ID ),
582 'title' => $attachment->post_title,
583 'size' => ( false !== $bytes ) ? wpatt_format_bytes( $bytes ) : '—',
584 'date' => wpatt_format_entry_date( $attachment->post_date ),
585 'caption' => $attachment->post_excerpt,
586 'description' => $attachment->post_content,
587 'author' => get_the_author_meta( 'display_name', $attachment->post_author ),
588 'downloads' => (int) wpa_get_downloads( $attachment->ID ),
589 'ext' => wpatt_get_file_extension( $attachment->ID ),
590 'mime' => (string) $attachment->post_mime_type,
591 );
592 }
593
594 /**
595 * Date of an attachment in the format chosen in the settings.
596 *
597 * @param string $post_date Date as stored in the post.
598 * @return string
599 */
600 function wpatt_format_entry_date( $post_date ) {
601 $format = get_option('wpatt_option_date_localization');
602 if ( '' === trim( (string) $format ) ) {
603 $format = get_option('date_format');
604 }
605
606 return date_i18n( $format, strtotime( $post_date ) );
607 }
608
609 /**
610 * One <li> of the list.
611 *
612 * @param int $template_id Template ID.
613 * @param string $template Template markup.
614 * @param array $data Values from wpatt_get_entry_data().
615 * @return string
616 */
617 function wpatt_render_list_item( $template_id, $template, array $data ) {
618 $type = wpatt_get_file_type( $data['mime'] );
619 $icon = wpatt_get_file_icon_svg( $type, 'wpa-icon' );
620
621 $html = apply_filters( 'wpatt_before_entry_html', $template );
622
623 if ( get_option('wpatt_option_targetblank') ) {
624 $html = str_replace('<a href', '<a target="_blank" rel="noopener noreferrer" href', $html);
625 }
626
627 // strtr() replaces in one pass, so a title that happens to contain a tag
628 // such as %URL% is not expanded a second time.
629 $html = strtr( $html, array(
630 '%URL%' => esc_url( $data['url'] ),
631 '%TITLE%' => esc_html( $data['title'] ),
632 '%SIZE%' => esc_html( $data['size'] ),
633 '%DATE%' => esc_html( $data['date'] ),
634 '%CAPTION%' => esc_html( $data['caption'] ),
635 '%DESCRIPTION%' => esc_html( $data['description'] ),
636 '%AUTHOR%' => esc_html( $data['author'] ),
637 '%DOWNLOADS%' => (int) $data['downloads'],
638 '%EXT%' => esc_html( $data['ext'] ),
639 '%MIME%' => esc_html( $data['mime'] ),
640 '%ICON%' => $icon,
641 ) );
642
643 $html = apply_filters( 'wpatt_after_entry_html', $html );
644
645 // Modern pack: the icon leads the entry. The Card template places it
646 // itself through %ICON%.
647 if ( WPATT_PACK_MODERN === wpatt_icon_pack() && WPATT_TEMPLATE_CARD !== (int) $template_id ) {
648 $html = $icon . $html;
649 }
650
651 $class = 'post-attachment mime-' . sanitize_title( $data['mime'] ) . ' wpa-type-' . $type;
652
653 return '<li class="' . esc_attr( $class ) . '">' . $html . '</li>';
654 }
655
656 /**
657 * Wrap the items in the list block.
658 *
659 * @param string $items <li> elements.
660 * @param string $head_html Heading and sort links, may be empty.
661 * @param int $template_id Template ID.
662 * @param array|null $card Modern Card options, from wpatt_get_card_options().
663 * @return string
664 */
665 function wpatt_wrap_list( $items, $head_html, $template_id, $card = null ) {
666 $is_card = ( WPATT_TEMPLATE_CARD === (int) $template_id );
667 $uses_svg = ( WPATT_PACK_MODERN === wpatt_icon_pack() || $is_card );
668
669 $colors = $uses_svg ? wpatt_icons_color_attrs() : array( 'class' => '', 'style' => '' );
670 $block_class = 'wpa-attachments-block' . $colors['class'];
671 $list_class = 'post-attachments';
672
673 if ( $is_card ) {
674 $card = is_array( $card ) ? $card : wpatt_get_card_options();
675 $list_class .= ' wpa-cards';
676 if ( 'rows' === $card['layout'] ) {
677 $list_class .= ' wpa-cards--rows';
678 } elseif ( 'normal' !== $card['width'] ) {
679 $list_class .= ' wpa-cards--' . $card['width'];
680 }
681 }
682
683 return '<!-- WP Attachments --><div class="' . esc_attr( $block_class ) . '"'
684 . ( $colors['style'] ? ' style="' . esc_attr( $colors['style'] ) . '"' : '' ) . '>' . $head_html
685 . '<ul class="' . esc_attr( $list_class ) . '">' . $items . '</ul></div>';
686 }
687
688 /**
689 * List preview for the settings screen, built from sample files.
690 *
691 * Goes through the same functions as the real list, so it shows what the
692 * site will show with the saved icon pack.
693 *
694 * @param int $template_id Template ID.
695 * @return string
696 */
697 function wpatt_render_template_preview( $template_id ) {
698 $samples = array(
699 array(
700 'title' => __( 'Annual report', 'wp-attachments' ),
701 'mime' => 'application/pdf',
702 'ext' => 'PDF',
703 'size' => '1.2 MB',
704 'caption' => __( 'Approved by the board', 'wp-attachments' ),
705 ),
706 array(
707 'title' => __( 'Application form', 'wp-attachments' ),
708 'mime' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
709 'ext' => 'DOCX',
710 'size' => '48 KB',
711 'caption' => '',
712 ),
713 );
714
715 $template = wpatt_get_template_string( $template_id );
716 $items = '';
717
718 foreach ( $samples as $i => $sample ) {
719 $items .= wpatt_render_list_item( $template_id, $template, array(
720 'url' => '#',
721 'title' => $sample['title'],
722 'size' => $sample['size'],
723 'date' => wpatt_format_entry_date( gmdate( 'Y-m-d H:i:s', time() - $i * DAY_IN_SECONDS ) ),
724 'caption' => $sample['caption'],
725 'description' => '',
726 'author' => wp_get_current_user()->display_name,
727 'downloads' => 12 - $i * 7,
728 'ext' => $sample['ext'],
729 'mime' => $sample['mime'],
730 ) );
731 }
732
733 return wpatt_wrap_list( $items, '', $template_id );
734 }
735
736
737 /* Register Settings */
738
739 function wpa_get_downloads($ID) {
740 if (get_post_meta($ID, "wpa-download", true)) {
741 return get_post_meta($ID, "wpa-download", true);
742 } else { return 0; }
743 }
744 /**
745 * Should this request be allowed to move a download counter at all?
746 *
747 * Filters out browser speculative loads and obvious automation, which would
748 * otherwise inflate the numbers without anybody having clicked anything.
749 *
750 * @return bool
751 */
752 function wpa_is_countable_request() {
753 // Chrome and Firefox announce prefetch / prerender / preview loads.
754 $speculative_headers = array( 'HTTP_SEC_PURPOSE', 'HTTP_PURPOSE', 'HTTP_X_PURPOSE', 'HTTP_X_MOZ' );
755 foreach ( $speculative_headers as $header ) {
756 if ( ! empty( $_SERVER[ $header ] )
757 && preg_match( '/prefetch|prerender|preview/i', (string) $_SERVER[ $header ] ) ) {
758 return false;
759 }
760 }
761
762 $agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
763
764 // No user agent at all is almost always a script.
765 $countable = ( '' !== $agent );
766
767 if ( $countable ) {
768 $bots = '/bot|crawl|spider|slurp|curl|wget|python-requests|okhttp|headless'
769 . '|facebookexternalhit|whatsapp|telegram|monitor|uptime|pingdom|lighthouse|preview/i';
770 $countable = ! preg_match( $bots, $agent );
771 }
772
773 /**
774 * Filter whether the current request may increment a download counter.
775 *
776 * @param bool $countable Whether the request looks like a real visitor.
777 */
778 return (bool) apply_filters( 'wpatt_count_download_request', $countable );
779 }
780
781 /**
782 * Has this visitor already been counted for this file recently?
783 *
784 * The throttle key is a salted hash held in a transient, so nothing
785 * identifying is written anywhere and the record expires by itself. The old
786 * implementation stored a plain text IP address in post meta, kept only one
787 * address per attachment -- which meant two visitors alternating cancelled
788 * each other's throttle -- and never expired.
789 *
790 * @param int $ID Attachment ID.
791 * @return bool True when the hit should be counted.
792 */
793 function wpa_is_valid_download( $ID ) {
794 $ID = absint( $ID );
795 if ( ! $ID ) {
796 return false;
797 }
798
799 // REMOTE_ADDR only: HTTP_CLIENT_IP and X_FORWARDED_FOR are attacker
800 // controlled, so trusting them made the throttle trivial to bypass.
801 $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : '';
802
803 // No usable address: count the hit rather than silently drop it.
804 if ( '' === $ip ) {
805 return true;
806 }
807
808 /**
809 * Filter how long the same visitor is ignored for the same file.
810 *
811 * @param int $seconds Throttle window.
812 * @param int $ID Attachment ID.
813 */
814 $window = (int) apply_filters( 'wpatt_download_throttle', 5 * MINUTE_IN_SECONDS, $ID );
815 if ( $window < 1 ) {
816 return true;
817 }
818
819 $key = 'wpa_dl_' . wp_hash( $ID . '|' . $ip );
820
821 if ( get_transient( $key ) ) {
822 return false;
823 }
824
825 set_transient( $key, 1, $window );
826
827 return true;
828 }
829
830 add_action('admin_init', 'wpa_register_initial_settings', 5);
831 // Also on activation: WP-CLI and automated installers activate without ever
832 // loading an admin page, which left a new site on the legacy defaults.
833 register_activation_hook(__FILE__, 'wpa_register_initial_settings');
834
835 add_action('admin_menu', function() {
836 add_options_page('WP Attachments - Settings', 'WP Attachments', 'manage_options', 'wpatt-option-page', 'wpatt_plugin_options');
837 });
838
839 /**
840 * Seed the options on first run only.
841 *
842 * add_option() leaves existing values alone, so an empty list header or
843 * date format saved on purpose is kept instead of being refilled on the
844 * next admin page load.
845 */
846 function wpa_register_initial_settings() {
847 // Every earlier version stored these two on its first admin page load,
848 // so their absence means a brand new install. Runs at priority 5, before
849 // the admin_init callback that writes wpa_version_number.
850 $fresh_install = ( false === get_option('wpatt_option_localization') && false === get_option('wpa_version_number') );
851
852 add_option('wpatt_option_localization', __('Attachments','wp-attachments'));
853 // Empty: follow the WordPress date format (Settings > General).
854 add_option('wpatt_option_date_localization', '');
855 add_option('wpatt_option_heading_tag', 'h3');
856 add_option('wpatt_icons_color', 'type');
857 add_option('wpatt_card', wpatt_card_defaults());
858
859 // New look for new installs only: updated sites keep what they show today.
860 add_option('wpa_ict', $fresh_install ? (string) WPATT_PACK_MODERN : '0');
861 add_option('wpa_template', $fresh_install ? (string) WPATT_TEMPLATE_CARD : '0');
862 }
863
864 /**
865 * Default options of the Modern Card template.
866 *
867 * @return array
868 */
869 function wpatt_card_defaults() {
870 return array(
871 'ext' => 1,
872 'size' => 1,
873 'date' => 1,
874 'downloads' => 0,
875 'caption' => 0,
876 'layout' => 'grid', // grid | rows
877 'width' => 'normal', // compact | normal | wide
878 );
879 }
880
881 /**
882 * Modern Card options: the saved ones, optionally overridden.
883 *
884 * The overrides are what a block or shortcode will pass for a single list,
885 * so one page can show a wide grid and another one card per row.
886 *
887 * @param array $overrides Options for this list only.
888 * @return array
889 */
890 function wpatt_get_card_options( array $overrides = array() ) {
891 $saved = get_option( 'wpatt_card', array() );
892 $opts = array_merge( wpatt_card_defaults(), is_array( $saved ) ? $saved : array(), $overrides );
893
894 foreach ( array( 'ext', 'size', 'date', 'downloads', 'caption' ) as $flag ) {
895 $opts[ $flag ] = empty( $opts[ $flag ] ) ? 0 : 1;
896 }
897 if ( ! in_array( $opts['layout'], array( 'grid', 'rows' ), true ) ) {
898 $opts['layout'] = 'grid';
899 }
900 if ( ! in_array( $opts['width'], array( 'compact', 'normal', 'wide' ), true ) ) {
901 $opts['width'] = 'normal';
902 }
903
904 return $opts;
905 }
906
907 /**
908 * Heading level of the list title, as chosen in the settings.
909 *
910 * @return string One of h1-h6.
911 */
912 function wpatt_heading_tag() {
913 $tag = get_option('wpatt_option_heading_tag', 'h3');
914
915 return in_array($tag, array('h1', 'h2', 'h3', 'h4', 'h5', 'h6'), true) ? $tag : 'h3';
916 }
917
918 ?>
919