Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
44.00% covered (danger)
44.00%
77 / 175
17.14% covered (danger)
17.14%
6 / 35
CRAP
0.00% covered (danger)
0.00%
0 / 1
Tus_File
43.93% covered (danger)
43.93%
76 / 173
17.14% covered (danger)
17.14%
6 / 35
1150.42
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 ensure_integer
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 set_meta
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 set_name
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 get_name
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 set_file_size
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 get_file_size
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 set_key
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 get_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set_checksum
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 get_checksum
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 set_offset
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 get_offset
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 set_location
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 get_location
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 set_file_path
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 get_file_path
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set_upload_metadata
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 get_input_stream
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set_input_stream
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 details
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 upload
80.77% covered (warning)
80.77%
21 / 26
0.00% covered (danger)
0.00%
0 / 1
9.58
 persist_upload_meta
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
6.20
 resume_md5_context
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 open
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
5.02
 exists
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
6.97
 seek
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 read
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 write
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 merge
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 copy
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 delete
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 delete_files
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 close
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_wp_filesystem
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Main
4 *
5 * @package VideoPressUploader
6 **/
7
8namespace VideoPressUploader;
9
10use InvalidArgumentException;
11
12// Avoid direct calls to this file.
13if ( ! defined( 'ABSPATH' ) ) {
14    die( 0 );
15}
16
17/**
18 * Class Tus_File.
19 *
20 * @package VideoPressUploader
21 */
22class Tus_File {
23    const CHUNK_SIZE    = 8192; // 8 kilobytes.
24    const INPUT_STREAM  = 'php://input';
25    const READ_BINARY   = 'rb';
26    const APPEND_BINARY = 'ab';
27
28    /**
29     * The input stream.
30     *
31     * @var string
32     */
33    protected static $input_stream = self::INPUT_STREAM;
34
35    /**
36     * The key.
37     *
38     * @var string The key.
39     */
40    protected $key;
41
42    /**
43     * The file checksum.
44     *
45     * @var string
46     */
47    protected $checksum;
48
49    /**
50     * The file name.
51     *
52     * @var string
53     */
54    protected $name;
55
56    /**
57     * The cache we are using.
58     *
59     * @var Tus_Abstract_Cache
60     */
61    protected $cache;
62
63    /**
64     * The current file offset.
65     *
66     * @var int
67     */
68    protected $offset;
69
70    /**
71     * The location.
72     *
73     * @var string
74     */
75    protected $location;
76
77    /**
78     * The file path.
79     *
80     * @var string
81     */
82    protected $file_path;
83
84    /**
85     * The file size.
86     *
87     * @var int
88     */
89    protected $file_size;
90
91    /**
92     * The upload metadata.
93     *
94     * @var array
95     */
96    private $upload_metadata = array();
97
98    /**
99     * File constructor.
100     *
101     * @param string|null             $name Name.
102     * @param Tus_Abstract_Cache|null $cache Cache.
103     */
104    public function __construct( $name = null, $cache = null ) {
105        $this->name  = $name;
106        $this->cache = $cache;
107    }
108
109    /**
110     * Returns an integer if it's castable. Otherwise it throws
111     *
112     * @param string|int $number Number to cast.
113     * @throws InvalidArgumentException If argument is invalid.
114     * @return int
115     */
116    public function ensure_integer( $number ) {
117        if ( ! is_numeric( $number ) ) {
118            throw new InvalidArgumentException( 'argument needs to be an integer. Check stacktrace' );
119        }
120        return (int) $number;
121    }
122
123    /**
124     * Set file meta.
125     *
126     * @param int    $offset Offset.
127     * @param int    $file_size File size.
128     * @param string $file_path File path.
129     * @param string $location Location.
130     *
131     * @throws InvalidArgumentException If argument is invalid.
132     * @return $this
133     */
134    public function set_meta( $offset, $file_size, $file_path, $location = null ) {
135        $offset    = $this->ensure_integer( $offset );
136        $file_size = $this->ensure_integer( $file_size );
137
138        if ( ! is_string( $file_path ) ) {
139            throw new InvalidArgumentException( '$file_path needs to be a string' );
140        }
141
142        $this->offset    = absint( $offset );
143        $this->file_size = absint( $file_size );
144        $this->file_path = $file_path;
145        $this->location  = $location;
146
147        return $this;
148    }
149
150    /**
151     * Set name.
152     *
153     * @param string $name Name.
154     *
155     * @throws InvalidArgumentException If argument is invalid.
156     *
157     * @return $this
158     */
159    public function set_name( $name ) {
160        if ( ! is_string( $name ) ) {
161            throw new InvalidArgumentException( '$name needs to be a string' );
162        }
163        $this->name = $name;
164
165        return $this;
166    }
167
168    /**
169     * Get name.
170     *
171     * @return string
172     */
173    public function get_name() {
174        return $this->name;
175    }
176
177    /**
178     * Set file size.
179     *
180     * @param int $size The size.
181     *
182     * @throws InvalidArgumentException If argument is invalid.
183     * @return Tus_File
184     */
185    public function set_file_size( $size ) {
186        $size            = $this->ensure_integer( $size );
187        $this->file_size = $size;
188
189        return $this;
190    }
191
192    /**
193     * Get file size.
194     *
195     * @return int
196     */
197    public function get_file_size() {
198        return $this->file_size;
199    }
200
201    /**
202     * Set key.
203     *
204     * @param string $key The key.
205     *
206     * @throws InvalidArgumentException If argument is invalid.
207     * @return Tus_File
208     */
209    public function set_key( $key ) {
210        if ( ! is_string( $key ) ) {
211            throw new InvalidArgumentException( '$key needs to be a string' );
212        }
213        $this->key = $key;
214
215        return $this;
216    }
217
218    /**
219     * Get key.
220     *
221     * @return string
222     */
223    public function get_key() {
224        return $this->key;
225    }
226
227    /**
228     * Set checksum.
229     *
230     * @param string $checksum The checksum.
231     *
232     * @throws InvalidArgumentException If argument is invalid.
233     * @return Tus_File
234     */
235    public function set_checksum( $checksum ) {
236        if ( ! is_string( $checksum ) ) {
237            throw new InvalidArgumentException( '$checksum needs to be a string' );
238        }
239        $this->checksum = $checksum;
240
241        return $this;
242    }
243
244    /**
245     * Get checksum.
246     *
247     * @return string
248     */
249    public function get_checksum() {
250        return $this->checksum;
251    }
252
253    /**
254     * Set offset.
255     *
256     * @param int $offset The offset.
257     *
258     * @throws InvalidArgumentException If argument is invalid.
259     * @return self
260     */
261    public function set_offset( $offset ) {
262        $offset       = $this->ensure_integer( $offset );
263        $this->offset = absint( $offset );
264
265        return $this;
266    }
267
268    /**
269     * Get offset.
270     *
271     * @return int
272     */
273    public function get_offset() {
274        return $this->offset;
275    }
276
277    /**
278     * Set location.
279     *
280     * @param string $location The location.
281     *
282     * @throws InvalidArgumentException If argument is invalid.
283     * @return self
284     */
285    public function set_location( $location ) {
286        if ( ! is_string( $location ) ) {
287            throw new InvalidArgumentException( '$location needs to be a string' );
288        }
289        $this->location = $location;
290
291        return $this;
292    }
293
294    /**
295     * Get location.
296     *
297     * @return string
298     */
299    public function get_location() {
300        return $this->location;
301    }
302
303    /**
304     * Set absolute file location.
305     *
306     * @param string $path The path.
307     *
308     * @return Tus_File
309     */
310    public function set_file_path( $path ) {
311        $this->file_path = $path;
312
313        return $this;
314    }
315
316    /**
317     * Get absolute location.
318     *
319     * @return string
320     */
321    public function get_file_path() {
322        return $this->file_path;
323    }
324
325    /**
326     * Set the upload meta.
327     *
328     * @param array $metadata The metadata.
329     *
330     * @return Tus_File
331     */
332    public function set_upload_metadata( array $metadata ) {
333        $this->upload_metadata = $metadata;
334
335        return $this;
336    }
337
338    /**
339     * Get input stream.
340     *
341     * @return string
342     */
343    public function get_input_stream() {
344        return self::$input_stream;
345    }
346
347    /**
348     * Set input stream. Useful for testing.
349     *
350     * @param string $stream The stream.
351     *
352     * @return void
353     */
354    public static function set_input_stream( $stream ) {
355        self::$input_stream = $stream;
356    }
357
358    /**
359     * Get file meta.
360     *
361     * @return array
362     * @throws \Exception If date fails.
363     */
364    public function details() {
365        $now = Tus_Date_Utils::date_utc();
366        $ttl = $this->cache->get_ttl();
367
368        return array(
369            'name'       => $this->name,
370            'size'       => $this->file_size,
371            'offset'     => $this->offset,
372            'checksum'   => $this->checksum,
373            'location'   => $this->location,
374            'file_path'  => $this->file_path,
375            'metadata'   => $this->upload_metadata,
376            'created_at' => $now->format( Tus_Abstract_Cache::RFC_7231 ),
377            'expires_at' => Tus_Date_Utils::add_seconds( $now, $ttl )->format( Tus_Abstract_Cache::RFC_7231 ),
378        );
379    }
380
381    /**
382     * Upload file to server.
383     *
384     * @param int $total_bytes The total bytes of the file.
385     *
386     * @return int
387     * @throws \Out_Of_Range_Exception Various exceptions.
388     * @throws \Connection_Exception Various exceptions.
389     * @throws File_Exception Various exceptions.
390     */
391    public function upload( $total_bytes ) {
392        if ( $this->offset === $total_bytes ) {
393            return $this->offset;
394        }
395
396        try {
397                    $input  = $this->open( $this->get_input_stream(), self::READ_BINARY );
398                    $output = $this->open( $this->get_file_path(), self::APPEND_BINARY );
399                    $key    = $this->get_key();
400        } catch ( File_Exception $fe ) {
401            Logger::log( 'error', $fe );
402            throw new File_Exception( 'Upload failed.' );
403        }
404
405        // Resume the rolling MD5 from cached state, if present.
406        $md5_context = $this->resume_md5_context( $key );
407
408        try {
409            $this->seek( $output, $this->offset );
410
411            while ( ! feof( $input ) ) {
412                if ( CONNECTION_NORMAL !== connection_status() ) {
413                    throw new \Connection_Exception( 'Connection aborted by user.' );
414                }
415
416                $data  = $this->read( $input, self::CHUNK_SIZE );
417                $bytes = $this->write( $output, $data, self::CHUNK_SIZE );
418
419                if ( null !== $md5_context && $bytes > 0 ) {
420                    // Hash only the bytes write() persisted (handles short writes).
421                    hash_update( $md5_context, substr( $data, 0, $bytes ) );
422                }
423
424                $this->offset += $bytes;
425
426                if ( $this->offset > $total_bytes ) {
427                    throw new \Out_Of_Range_Exception( 'The uploaded file is corrupt.' );
428                }
429
430                if ( $this->offset === $total_bytes ) {
431                    break;
432                }
433            }
434        } finally {
435            $this->close( $input );
436            $this->close( $output );
437
438            $this->persist_upload_meta( $key, $md5_context, $total_bytes );
439        }
440
441        return $this->offset;
442    }
443
444    /**
445     * Persist the upload offset and, when hashing is active, the rolling or final MD5.
446     *
447     * Mirrors resume_md5_context(): that reads the rolling state at the start of a request, this writes
448     * it back at the end. The digest is built inside its own guard so a hashing failure can never skip
449     * the offset write below.
450     *
451     * @since 0.40.0
452     *
453     * @param string            $key         The upload key.
454     * @param \HashContext|null $md5_context The rolling MD5 context, or null when hashing is inactive.
455     * @param int               $total_bytes The expected final size, used to detect completion.
456     */
457    private function persist_upload_meta( $key, $md5_context, $total_bytes ) {
458        $meta = array( 'offset' => $this->offset );
459
460        if ( null !== $md5_context ) {
461            try {
462                if ( $this->offset === $total_bytes ) {
463                    $meta['md5']       = hash_final( $md5_context );
464                    $meta['md5_state'] = null;
465                } else {
466                    $meta['md5_state'] = base64_encode( serialize( $md5_context ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions
467                }
468            } catch ( \Throwable $e ) {
469                Logger::log( 'error', $e );
470            }
471        }
472
473        try {
474            $this->cache->set( $key, $meta );
475        } catch ( \Throwable $e ) {
476            Logger::log( 'error', $e );
477        }
478    }
479
480    /**
481     * Resume the rolling MD5 stored for this upload, or null to skip hashing.
482     *
483     * The context is read from the cached `md5_state` (a base64-encoded serialized HashContext) so
484     * written bytes can be folded into it. Returns null when no usable state is present, leaving the
485     * caller to hash the assembled file instead.
486     *
487     * @since 0.40.0
488     *
489     * @param string $key The upload key.
490     *
491     * @return \HashContext|null
492     */
493    private function resume_md5_context( $key ) {
494        $meta = $this->cache->get( $key );
495
496        if ( ! is_array( $meta ) || ! isset( $meta['md5_state'] ) ) {
497            return null;
498        }
499
500        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
501        $decoded = base64_decode( $meta['md5_state'], true );
502
503        if ( false === $decoded ) {
504            return null;
505        }
506
507        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
508        $context = unserialize( $decoded, array( 'allowed_classes' => array( 'HashContext' ) ) );
509
510        return $context instanceof \HashContext ? $context : null;
511    }
512
513    /**
514     * Open file in given mode.
515     *
516     * @param string $file_path The file path.
517     * @param string $mode The mode.
518     *
519     * @return resource
520     * @throws File_Exception Exc.
521     * @throws InvalidArgumentException If argument is invalid.
522     */
523    public function open( $file_path, $mode ) {
524        if ( ! is_string( $file_path ) ) {
525            throw new InvalidArgumentException( '$file_path needs to be a string' );
526        }
527        if ( ! is_string( $mode ) ) {
528            throw new InvalidArgumentException( '$mode needs to be a string' );
529        }
530        $this->exists( $file_path, $mode );
531
532        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged
533        $ptr = @fopen( $file_path, $mode );
534
535        if ( false === $ptr ) {
536            Logger::log( 'error', "Unable to open file at $file_path." );
537            throw new File_Exception( 'Unable to open file.' );
538        }
539
540        return $ptr;
541    }
542
543    /**
544     * Check if file to read exists.
545     *
546     * @param string $file_path The file path.
547     * @param string $mode The mode.
548     *
549     * @return bool
550     * @throws File_Exception File.
551     * @throws InvalidArgumentException If argument is invalid.
552     */
553    public function exists( $file_path, $mode = self::READ_BINARY ) {
554        if ( ! is_string( $file_path ) ) {
555            throw new InvalidArgumentException( '$file_path needs to be a string' );
556        }
557        if ( self::INPUT_STREAM === $file_path ) {
558            return true;
559        }
560
561        if ( self::READ_BINARY === $mode && ! file_exists( $file_path ) ) {
562            throw new File_Exception( 'File not found.' );
563        }
564
565        return true;
566    }
567
568    /**
569     * Move file pointer to given offset using fseek.
570     *
571     * @param resource $handle The handle.
572     * @param int      $offset The offset.
573     * @param int      $whence The whence.
574     *
575     * @throws File_Exception Exc.
576     *
577     * @return int
578     */
579    public function seek( $handle, $offset, $whence = SEEK_SET ) {
580        $offset   = $this->ensure_integer( $offset );
581        $position = fseek( $handle, $offset, $whence );
582
583        if ( -1 === $position ) {
584            throw new File_Exception( 'Cannot move pointer to desired position.' );
585        }
586
587        return $position;
588    }
589
590    /**
591     * Read data from file.
592     *
593     * @param resource $handle The handle.
594     * @param int      $chunk_size Chunk size.
595     *
596     * @return string
597     * @throws File_Exception If no data is read.
598     */
599    public function read( $handle, $chunk_size ) {
600        $chunk_size = $this->ensure_integer( $chunk_size );
601        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
602        $data = fread( $handle, $chunk_size );
603
604        if ( false === $data ) {
605            throw new File_Exception( 'Cannot read file.' );
606        }
607
608        return (string) $data;
609    }
610
611    /**
612     * Write data to file.
613     *
614     * @param resource $handle The file handle.
615     * @param string   $data The data to write.
616     * @param int|null $length Possibly the length of the data.
617     *
618     * @throws File_Exception When can't write.
619     *
620     * @return int
621     */
622    public function write( $handle, $data, $length = null ) {
623        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
624        $bytes_written = \is_numeric( $length ) ? fwrite( $handle, $data, intval( $length ) ) : fwrite( $handle, $data );
625
626        if ( false === $bytes_written ) {
627            throw new File_Exception( 'Cannot write to a file.' );
628        }
629
630        return $bytes_written;
631    }
632
633    /**
634     * Merge 2 or more files.
635     *
636     * @param array $files File data with meta info.
637     *
638     * @return int
639     * @throws File_Exception When the file to be merged is not found.
640     */
641    public function merge( array $files ) {
642        $destination = $this->get_file_path();
643        $first_file  = array_shift( $files );
644
645        // First partial file can directly be copied.
646        $this->copy( $first_file['file_path'], $destination );
647
648        $this->offset    = $first_file['offset'];
649        $this->file_size = filesize( $first_file['file_path'] );
650
651        $handle = $this->open( $destination, self::APPEND_BINARY );
652
653        foreach ( $files as $file ) {
654            if ( ! file_exists( $file['file_path'] ) ) {
655                throw new File_Exception( 'File to be merged not found.' );
656            }
657
658            $this->file_size += $this->write( $handle, $this->get_wp_filesystem()->get_contents( $file['file_path'] ) );
659
660            $this->offset += $file['offset'];
661        }
662
663        $this->close( $handle );
664
665        return $this->file_size;
666    }
667
668    /**
669     * Copy file from source to destination.
670     *
671     * @param string $source The source.
672     * @param string $destination The destination.
673     *
674     * @return bool
675     * @throws File_Exception If copy fails.
676     */
677    public function copy( $source, $destination ) {
678        $status = copy( $source, $destination );
679
680        if ( ! $status ) {
681            Logger::log( 'error', sprintf( 'Cannot copy source (%s) to destination (%s).', $source, $destination ) );
682            throw new File_Exception( 'Cannot copy source file to destination file.' );
683        }
684
685        return $status;
686    }
687
688    /**
689     * Delete file and/or folder.
690     *
691     * @param array $files The files.
692     * @param bool  $folder The folder.
693     *
694     * @return bool
695     */
696    public function delete( array $files, $folder = false ) {
697        $status = $this->delete_files( $files );
698
699        if ( $status && $folder ) {
700            return $this->get_wp_filesystem()->rmdir( \dirname( current( $files ) ) );
701        }
702
703        return $status;
704    }
705
706    /**
707     * Delete multiple files.
708     *
709     * @param array $files The files.
710     *
711     * @return bool
712     */
713    public function delete_files( array $files ) {
714        if ( empty( $files ) ) {
715            return false;
716        }
717
718        $status = true;
719
720        foreach ( $files as $file ) {
721            if ( $this->get_wp_filesystem()->exists( $file ) ) {
722                $r      = $this->get_wp_filesystem()->delete( $file );
723                $status = $status && $r;
724            }
725        }
726
727        return $status;
728    }
729
730    /**
731     * Close file.
732     *
733     * @param mixed $handle The handle.
734     *
735     * @return bool
736     */
737    public function close( $handle ) {
738        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
739        return fclose( $handle );
740    }
741
742    /**
743     * Get the wp filesystem.
744     *
745     * @return \WP_Filesystem_Base|null
746     */
747    private function get_wp_filesystem() {
748        global $wp_filesystem;
749
750        if ( ! isset( $wp_filesystem ) ) {
751            require_once ABSPATH . '/wp-admin/includes/file.php';
752            WP_Filesystem();
753        }
754
755        return $wp_filesystem;
756    }
757}