Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
7.78% covered (danger)
7.78%
14 / 180
17.65% covered (danger)
17.65%
3 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
jetpack_clean_revision_history
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
Jetpack_Media
7.43% covered (danger)
7.43%
13 / 175
12.50% covered (danger)
12.50%
2 / 16
1724.60
0.00% covered (danger)
0.00%
0 / 1
 generate_new_filename
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 get_time_string_from_guid
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 get_allowed_mime_types
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 is_file_supported_for_sideloading
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 save_temporary_file
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 get_snapshot
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 register_revision
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 get_revision_history
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_original_media
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 delete_file
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 delete_media_history_file
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 remove_items_from_revision_history
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
 limit_revision_history
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 clean_original_media
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
 clean_revision_history
40.00% covered (danger)
40.00%
6 / 15
0.00% covered (danger)
0.00%
0 / 1
2.86
 edit_media_file
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
30
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2
3if ( ! defined( 'ABSPATH' ) ) {
4    exit( 0 );
5}
6
7require_once JETPACK__PLUGIN_DIR . 'sal/class.json-api-date.php';
8
9/**
10 * Class to handle different actions related to media.
11 */
12class Jetpack_Media {
13    /**
14     * Original media meta data. Metadata key as stored by WP.
15     *
16     * @var string
17     */
18    const WP_ORIGINAL_MEDIA = '_wp_original_post_media';
19    /**
20     * Revision history. Metadata key as stored by WP.
21     *
22     * @var string
23     */
24    const WP_REVISION_HISTORY = '_wp_revision_history';
25    /**
26     * Maximum amount of revisions.
27     *
28     * @var int
29     */
30    const REVISION_HISTORY_MAXIMUM_AMOUNT = 0;
31    /**
32     * Image Alt. Metadata key as stored by WP.
33     *
34     * @var string
35     */
36    const WP_ATTACHMENT_IMAGE_ALT = '_wp_attachment_image_alt';
37
38    /**
39     * Generate a filename in function of the original filename of the media.
40     * The returned name has the `{basename}-{hash}-{random-number}.{ext}` shape.
41     * The hash is built according to the filename trying to avoid name collisions
42     * with other media files.
43     *
44     * @param  int    $media_id - media post ID.
45     * @param  string $new_filename - the new filename.
46     * @return string A random filename.
47     */
48    public static function generate_new_filename( $media_id, $new_filename ) {
49        // Get the right filename extension.
50        $new_filename_paths = pathinfo( $new_filename );
51        $new_file_ext       = $new_filename_paths['extension'];
52
53        // Get the file parts from the current attachment.
54        $current_file         = get_attached_file( $media_id );
55        $current_file_parts   = pathinfo( $current_file );
56        $current_file_ext     = $current_file_parts['extension'];
57        $current_file_dirname = $current_file_parts['dirname'];
58
59        // Take out filename from the original file or from the current attachment.
60        $original_media = (array) self::get_original_media( $media_id );
61
62        if ( ! empty( $original_media ) ) {
63            $original_file_parts = pathinfo( $original_media['file'] );
64            $filename_base       = $original_file_parts['filename'];
65        } else {
66            $filename_base = $current_file_parts['filename'];
67        }
68
69        // Add unique seed based on the filename.
70        $filename_base .= '-' . crc32( $filename_base ) . '-';
71
72        $number_suffix = time() . wp_rand( 100, 999 );
73
74        do {
75            $filename  = $filename_base;
76            $filename .= "e{$number_suffix}";
77            $file_ext  = $new_file_ext ? $new_file_ext : $current_file_ext;
78
79            $new_filename = "{$filename}.{$file_ext}";
80            $new_path     = "{$current_file_dirname}/$new_filename";
81            ++$number_suffix;
82        } while ( file_exists( $new_path ) );
83
84        return $new_filename;
85    }
86
87    /**
88     * File urls use the post (image item) date to generate a folder path.
89     * Post dates can change, so we use the original date used in the `guid`
90     * url so edits can remain in the same folder. In the following function
91     * we capture a string in the format of `YYYY/MM` from the guid.
92     *
93     * For example with a guid of
94     * "http://test.files.wordpress.com/2016/10/test.png" the resulting string
95     * would be: "2016/10"
96     *
97     * @param int $media_id Attachment ID.
98     * @return string
99     */
100    private static function get_time_string_from_guid( $media_id ) {
101        $time = gmdate( 'Y/m', strtotime( current_time( 'mysql' ) ) );
102
103        $media = get_post( $media_id );
104        if ( $media ) {
105            $pattern = '/\/(\d{4}\/\d{2})\//';
106            preg_match( $pattern, $media->guid, $matches );
107            if ( count( $matches ) > 1 ) {
108                $time = $matches[1];
109            }
110        }
111        return $time;
112    }
113
114    /**
115     * Return an array of allowed mime_type items used to upload a media file.
116     *
117     * @param array $default_mime_types Array of mime types.
118     *
119     * @return array mime_type array
120     */
121    public static function get_allowed_mime_types( $default_mime_types ) {
122        return array_unique(
123            array_merge(
124                $default_mime_types,
125                array(
126                    'application/msword',                                                         // .doc
127                    'application/vnd.ms-powerpoint',                                              // .ppt, .pps
128                    'application/vnd.ms-excel',                                                   // .xls
129                    'application/vnd.openxmlformats-officedocument.presentationml.presentation',  // .pptx
130                    'application/vnd.openxmlformats-officedocument.presentationml.slideshow',     // .ppsx
131                    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',          // .xlsx
132                    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',    // .docx
133                    'application/vnd.oasis.opendocument.text',                                    // .odt
134                    'application/pdf',                                                            // .pdf
135                )
136            )
137        );
138    }
139
140    /**
141     * Checks that the mime type of the file
142     * is among those in a filterable list of mime types.
143     *
144     * @param  string $file Path to file to get its mime type.
145     * @return bool
146     */
147    protected static function is_file_supported_for_sideloading( $file ) {
148        return jetpack_is_file_supported_for_sideloading( $file );
149    }
150
151    /**
152     * Save the given uploaded temporary file considering file type,
153     * correct location according to the original file path, etc.
154     * The file type control is done through of `jetpack_supported_media_sideload_types` filter,
155     * which allows define to the users their own file types list.
156     *
157     * Note this does not support sideloads, only uploads.
158     *
159     * @param  array $file_array Data derived from `$_FILES` for an uploaded file.
160     * @param  int   $media_id   Attachment ID.
161     * @return array|WP_Error an array with information about the new file saved or a WP_Error is something went wrong.
162     */
163    public static function save_temporary_file( $file_array, $media_id ) {
164        $tmp_filename = $file_array['tmp_name'];
165
166        if ( ! is_uploaded_file( $tmp_filename ) ) {
167            return new WP_Error( 'invalid_input', 'No media provided in input.' );
168        }
169
170        // add additional mime_types through of the `jetpack_supported_media_sideload_types` filter.
171        $mime_type_static_filter = array(
172            'Jetpack_Media',
173            'get_allowed_mime_types',
174        );
175
176        add_filter( 'jetpack_supported_media_sideload_types', $mime_type_static_filter );
177        if (
178            ! self::is_file_supported_for_sideloading( $tmp_filename ) &&
179            ! file_is_displayable_image( $tmp_filename )
180        ) {
181            return new WP_Error( 'invalid_input', 'Invalid file type.', 403 );
182        }
183        remove_filter( 'jetpack_supported_media_sideload_types', $mime_type_static_filter );
184
185        // generate a new file name.
186        $tmp_new_filename = self::generate_new_filename( $media_id, $file_array['name'] );
187
188        // start to create the parameters to move the temporal file.
189        $overrides = array( 'test_form' => false );
190
191        // get time according to the original filaname.
192        $time = self::get_time_string_from_guid( $media_id );
193
194        $file_array['name'] = $tmp_new_filename;
195        $file               = wp_handle_upload( $file_array, $overrides, $time );
196
197        if ( isset( $file['error'] ) ) {
198            return new WP_Error( 'upload_error', $file['error'] );
199        }
200
201        return $file;
202    }
203
204    /**
205     * Return an object with an snapshot of a revision item.
206     *
207     * @param  object $media_item - media post object.
208     * @return object a revision item
209     */
210    public static function get_snapshot( $media_item ) {
211        $current_file = get_attached_file( $media_item->ID );
212        $file_paths   = pathinfo( $current_file );
213
214        $snapshot = array(
215            'date'      => (string) WPCOM_JSON_API_Date::format_date( $media_item->post_modified_gmt, $media_item->post_modified ),
216            'URL'       => (string) wp_get_attachment_url( $media_item->ID ),
217            'file'      => (string) $file_paths['basename'],
218            'extension' => (string) $file_paths['extension'],
219            'mime_type' => (string) $media_item->post_mime_type,
220            'size'      => (int) filesize( $current_file ),
221        );
222
223        return (object) $snapshot;
224    }
225
226    /**
227     * Add a new item into revision_history array.
228     *
229     * @param  object         $media_item - media post object.
230     * @param  array|WP_Error $file - File data, or WP_Error on error.
231     * @param  bool           $has_original_media - condition is the original media has been already added.
232     * @return bool `true` if the item has been added. Otherwise `false`.
233     */
234    public static function register_revision( $media_item, $file, $has_original_media ) {
235        if ( is_wp_error( $file ) || ! $has_original_media ) {
236            return false;
237        }
238
239        add_post_meta( $media_item->ID, self::WP_REVISION_HISTORY, self::get_snapshot( $media_item ) );
240    }
241    /**
242     * Return the `revision_history` of the given media.
243     *
244     * @param  int $media_id - media post ID.
245     * @return array `revision_history` array
246     */
247    public static function get_revision_history( $media_id ) {
248        return array_reverse( get_post_meta( $media_id, self::WP_REVISION_HISTORY, false ) );
249    }
250
251    /**
252     * Return the original media data.
253     *
254     * @param int $media_id Attachment ID.
255     */
256    public static function get_original_media( $media_id ) {
257        $original = get_post_meta( $media_id, self::WP_ORIGINAL_MEDIA, true );
258        $original = $original ? $original : array();
259        return $original;
260    }
261
262    /**
263     * Delete a file.
264     *
265     * @param string $pathname Path name.
266     */
267    public static function delete_file( $pathname ) {
268        if ( ! file_exists( $pathname ) || ! is_file( $pathname ) ) {
269            $dir = dirname( $pathname );
270            if ( is_dir( $dir ) ) {
271                // let's touch a fake file to try to `really` remove the media file.
272                touch( $pathname ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch
273            }
274        }
275
276        return wp_delete_file( $pathname );
277    }
278
279    /**
280     * Try to delete a file according to the dirname of
281     * the media attached file and the filename.
282     *
283     * @param  int    $media_id - media post ID.
284     * @param  string $filename - basename of the file ( name-of-file.ext ).
285     *
286     * @return void
287     */
288    private static function delete_media_history_file( $media_id, $filename ) {
289        $attached_path  = get_attached_file( $media_id );
290        $attached_parts = pathinfo( $attached_path );
291        $dirname        = $attached_parts['dirname'];
292
293        $pathname = $dirname . '/' . $filename;
294
295        // remove thumbnails.
296        $metadata = wp_generate_attachment_metadata( $media_id, $pathname );
297
298        if ( isset( $metadata ) && isset( $metadata['sizes'] ) ) {
299            foreach ( $metadata['sizes'] as $properties ) {
300                self::delete_file( $dirname . '/' . $properties['file'] );
301            }
302        }
303
304        // remove primary file.
305        self::delete_file( $pathname );
306    }
307
308    /**
309     * Remove specific items from the `revision history` array
310     * depending on the given criteria: array(
311     *   'from' => (int) <from>,
312     *   'to' =>   (int) <to>,
313     * )
314     *
315     * Also, it removes the file defined in each item.
316     *
317     * @param int   $media_id - media post ID.
318     * @param array $criteria - criteria to remove the items.
319     * @param array $revision_history - revision history array.
320     *
321     * @return array `revision_history` array updated.
322     */
323    public static function remove_items_from_revision_history( $media_id, $criteria, $revision_history ) {
324        if ( ! isset( $revision_history ) ) {
325            $revision_history = self::get_revision_history( $media_id );
326        }
327
328        $from = $criteria['from'];
329        $to   = $criteria['to'] ? $criteria['to'] : ( $from + 1 );
330
331        for ( $i = $from; $i < $to; $i++ ) {
332            $removed_item = array_slice( $revision_history, $from, 1 );
333            if ( ! $removed_item ) {
334                break;
335            }
336
337            array_splice( $revision_history, $from, 1 );
338            self::delete_media_history_file( $media_id, $removed_item[0]->file );
339        }
340
341        // override all history items.
342        delete_post_meta( $media_id, self::WP_REVISION_HISTORY );
343        $revision_history = array_reverse( $revision_history );
344        foreach ( $revision_history as &$item ) {
345            add_post_meta( $media_id, self::WP_REVISION_HISTORY, $item );
346        }
347
348        return $revision_history;
349    }
350
351    /**
352     * Limit the number of items of the `revision_history` array.
353     * When the stack is overflowing the oldest item is remove from there (FIFO).
354     *
355     * @param int      $media_id - media post ID.
356     * @param null|int $limit - maximum amount of items. 20 as default.
357     *
358     * @return array items removed from `revision_history`
359     */
360    public static function limit_revision_history( $media_id, $limit = null ) {
361        if ( $limit === null ) {
362            $limit = self::REVISION_HISTORY_MAXIMUM_AMOUNT;
363        }
364
365        $revision_history = self::get_revision_history( $media_id );
366
367        $total = count( $revision_history );
368
369        if ( $total < $limit ) {
370            return array();
371        }
372
373        self::remove_items_from_revision_history(
374            $media_id,
375            array(
376                'from' => $limit,
377                'to'   => $total,
378            ),
379            $revision_history
380        );
381
382        return self::get_revision_history( $media_id );
383    }
384
385    /**
386     * Remove the original file and clean the post metadata.
387     *
388     * @param int $media_id - media post ID.
389     */
390    public static function clean_original_media( $media_id ) {
391        $original_file = self::get_original_media( $media_id );
392
393        if ( ! $original_file ) {
394            return null;
395        }
396
397        self::delete_media_history_file( $media_id, $original_file->file );
398        return delete_post_meta( $media_id, self::WP_ORIGINAL_MEDIA );
399    }
400
401    /**
402     * Clean `revision_history` of the given $media_id. it means:
403     *   - remove all media files tied to the `revision_history` items.
404     *   - clean `revision_history` meta data.
405     *   - remove and clean the `original_media`
406     *
407     * @param int $media_id - media post ID.
408     *
409     * @return array results of removing these files
410     */
411    public static function clean_revision_history( $media_id ) {
412        self::clean_original_media( $media_id );
413
414        $revision_history = self::get_revision_history( $media_id );
415        $total            = count( $revision_history );
416        $updated_history  = array();
417
418        if ( $total < 1 ) {
419            return $updated_history;
420        }
421
422        $updated_history = self::remove_items_from_revision_history(
423            $media_id,
424            array(
425                'from' => 0,
426                'to'   => $total,
427            ),
428            $revision_history
429        );
430
431        return $updated_history;
432    }
433
434    /**
435     * Edit media item process:
436     *
437     * - update attachment file
438     * - preserve original media file
439     * - trace revision history
440     *
441     * Note this does not support sideloads, only uploads.
442     *
443     * @param  int   $media_id - media post ID.
444     * @param  array $file_array - Data derived from `$_FILES` for an uploaded file.
445     * @return WP_Post|WP_Error Updated media item or a WP_Error is something went wrong.
446     */
447    public static function edit_media_file( $media_id, $file_array ) {
448        $media_item         = get_post( $media_id );
449        $has_original_media = self::get_original_media( $media_id );
450
451        if ( ! $has_original_media ) {
452
453            // The first time that the media is updated
454            // the original media is stored into the revision_history.
455            $snapshot = self::get_snapshot( $media_item );
456            add_post_meta( $media_id, self::WP_ORIGINAL_MEDIA, $snapshot, true );
457        }
458
459        // Save temporary file in the correct location.
460        $uploaded_file = self::save_temporary_file( $file_array, $media_id );
461
462        if ( is_wp_error( $uploaded_file ) ) {
463            return $uploaded_file;
464        }
465
466        // Revision_history control.
467        self::register_revision( $media_item, $uploaded_file, $has_original_media );
468
469        $uploaded_path     = $uploaded_file['file'];
470        $udpated_mime_type = $uploaded_file['type'];
471        $was_updated       = update_attached_file( $media_id, $uploaded_path );
472
473        if ( ! $was_updated ) {
474            return new WP_Error( 'update_error', 'Media update error' );
475        }
476
477        // Check maximum amount of revision_history before updating the attachment metadata.
478        self::limit_revision_history( $media_id );
479
480        $new_metadata = wp_generate_attachment_metadata( $media_id, $uploaded_path );
481        wp_update_attachment_metadata( $media_id, $new_metadata );
482
483        $edited_action = wp_update_post(
484            (object) array(
485                'ID'             => $media_id,
486                'post_mime_type' => $udpated_mime_type,
487            ),
488            true
489        );
490
491        if ( is_wp_error( $edited_action ) ) {
492            return $edited_action;
493        }
494
495        return $media_item;
496    }
497}
498
499// phpcs:disable Universal.Files.SeparateFunctionsFromOO.Mixed -- TODO: Move these functions to some other file.
500
501/**
502 * Clean revision history when the media item is deleted.
503 *
504 * @param int $media_id Attachment ID.
505 */
506function jetpack_clean_revision_history( $media_id ) {
507    Jetpack_Media::clean_revision_history( $media_id );
508}
509add_action( 'delete_attachment', 'jetpack_clean_revision_history' );