| 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>• %SIZE% • %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 |
|