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
← All changes | wp-attachments.php +888 -139 3.5 → 6.1 View file →
@@ -1,169 +1,918 @@
1 1 <?php
2 2 /*
3 -Plugin Name: WP Attachments
4 -Plugin URI: http://marcomilesi.ml
5 -Description: Automatically shows your attachments under every post and page content. Simple. Automatic. Easy. As it has to be!
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 6 Author: Marco Milesi
7 -Version: 3.5
8 -Author URI: http://marcomilesi.ml
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
9 14 */
10 15
11 -function wpa_action_init()
12 -{
13 - load_plugin_textdomain( 'wp-attachments', false, dirname( plugin_basename( __FILE__ ) ) . '/languages/' );
14 - update_option( 'wpa_version_number', '3.4' );
15 - wp_enqueue_style('wpa-css', plugin_dir_url(__FILE__) . 'styles/frontend.css');
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);
16 47 }
17 48
18 -// Add actions
19 -add_action('init', 'wpa_action_init');
20 -require_once(plugin_dir_path(__FILE__) . 'settings.php');
21 -require_once(plugin_dir_path(__FILE__) . 'ij-post-attachments.php');
22 -require_once(plugin_dir_path(__FILE__) . 'attach_unattach_reattach.php');
49 +/** Modern icon pack: inline SVG icons instead of PNG backgrounds. */
50 +define( 'WPATT_PACK_MODERN', 5 );
23 51
24 -function wpatt_format_bytes($a_bytes)
25 - {
26 -
27 - if ($a_bytes < 1024)
28 - {
29 -
30 - return '< 1KB';
31 -
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 + }
32 206 }
33 - elseif ($a_bytes < 1048576)
34 - {
35 -
36 - return round($a_bytes / 1024, 2) . ' KB';
37 -
38 - }
39 - elseif ($a_bytes < 1073741824)
40 - {
41 -
42 - return round($a_bytes / 1048576, 2) . ' MB';
43 -
44 - }
45 - elseif ($a_bytes < 1099511627776)
46 - {
47 -
48 - return round($a_bytes / 1073741824, 2) . ' GB';
49 -
50 - }
51 - else
52 - {
53 -
54 - return round($a_bytes / 1208925819614629174706176, 2) . ' ERROR';
55 -
56 - }
57 -
58 207 }
59 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 +}
60 217
61 -add_filter('the_content', 'wpatt_job_cpt_template_filter');
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 + }
62 230
63 -function wpatt_job_cpt_template_filter($content)
64 - {
65 - global $post;
66 - $somethingtoshow = 0;
67 - $content_l = null;
68 -
69 - if ($post->ID == '0' || $post->ID == NULL) { return $content; } //Skip the attachments list if POST ID is null
70 -
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 +
71 439 $attachments = get_posts(array(
72 - 'post_type' => 'attachment',
73 - 'orderby' => 'menu_order',
74 - 'order' => 'ASC',
75 - 'posts_per_page' => 100,
76 - 'post_parent' => $post->ID
440 + 'post_type' => 'attachment',
441 + 'orderby' => $orderby,
442 + 'order' => $order,
443 + 'posts_per_page' => -1,
444 + 'post_status' => 'any',
445 + 'post_parent' => $parent_id
77 446 ));
78 -
79 - if ($attachments)
80 - {
81 - $content_l .= '<div style="width:100%;float:left;margin:10px 0 10px 0;"><h3>' . get_option('wpatt_option_localization') . '</h3>
82 - <ul class="post-attachments">';
83 -
84 - foreach ($attachments as $attachment)
85 - {
86 -
87 - $wpatt_option_includeimages_get = get_option('wpatt_option_includeimages');
88 - if ($wpatt_option_includeimages_get == '1') {
89 - } else if ( wp_attachment_is_image( $attachment->ID ) ) {
90 - continue;
91 - }
92 - $somethingtoshow = 1;
93 -
94 - $class = "post-attachment mime-" . sanitize_title($attachment->post_mime_type);
95 -
96 - $wpatt_option_targetblank_get = get_option('wpatt_option_targetblank');
97 -
98 - $content_l .= '<li class="' . $class . '"><a ';
99 -
100 - if ($wpatt_option_targetblank_get == '1') { $content_l .= 'target="_blank" '; }
101 -
102 - if ((file_exists(get_attached_file($attachment->ID)))) {
103 - $wpatt_fs = wpatt_format_bytes(filesize(get_attached_file($attachment->ID)));
104 - } else {
105 - $wpatt_fs = 'not found';
106 - }
107 -
108 - $content_l .= 'href="' . wp_get_attachment_url($attachment->ID) . '">' . $attachment->post_title . '</a> (' . $wpatt_fs;
109 447
110 -
111 - $wpatt_option_showdate_get = get_option('wpatt_option_showdate');
112 -
113 - if ($wpatt_option_showdate_get == '1')
114 - {
115 -
116 - $wpatt_date = new DateTime($attachment->post_date);
117 -
118 - $content_l .= '<div style="float:right;">' . $wpatt_date->format('d.m.Y') . '</div>';
119 -
120 - }
121 -
122 - $content_l .= ')</li>';
123 -
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;
124 492 }
125 - $content_l .= '</ul></div>';
126 -
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;
127 500 }
128 - if ($somethingtoshow == 1) {
129 - $content .= $content_l;
130 - }
501 +
502 + if ( $toShow ) {
503 + $content .= apply_filters( 'wpatt_list_html', wpatt_wrap_list( $items, $head_html, $template_id ) );
504 + }
505 + }
131 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>';
132 532 }
533 +}
133 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 + }
134 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 +
135 737 /* Register Settings */
136 738
137 -add_action('admin_init', 'wpatt_reg_settings');
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 + }
138 761
139 -function wpatt_reg_settings()
140 - {
141 -
142 - register_setting('wpatt_options_group', 'wpatt_option_showdate', 'intval');
143 -
144 - register_setting('wpatt_options_group', 'wpatt_option_includeimages', 'intval');
145 -
146 - register_setting('wpatt_options_group', 'wpatt_option_localization');
147 -
148 - register_setting('wpatt_options_group', 'wpatt_disable_backend');
149 -
150 - register_setting('wpatt_options_group', 'wpatt_option_targetblank', 'intval');
151 -
152 - /* Preopulate 'Attachments' */
153 -
154 - $wpatt_option_showdate_get = get_option('wpatt_option_showdate');
155 -
156 - if (get_option('wpatt_option_localization') == '')
157 - {
158 - $value = __('Attachments','wp-attachments');
159 - update_option('wpatt_option_localization', $value);
160 - }
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 );
161 771 }
162 772
163 -add_action('admin_menu', 'wpatt_plugin_menu');
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 +}
164 780
165 -function wpatt_plugin_menu(){
166 - add_options_page('WP Attachments - Settings', 'WP Attachments', 'manage_options', 'wpatt-option-page', 'wpatt_plugin_options');
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;
167 828 }
168 829
169 -?>
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 +?>