Recycle::generateDeletionList()   B
last analyzed

Complexity

Conditions 6
Paths 8

Size

Total Lines 27
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 27
rs 8.439
cc 6
eloc 16
nc 8
nop 2
1
<?php
2
/**
3
 *
4
 * © 2015 Tolan Blundell.  All rights reserved.
5
 * <[email protected]>
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 *
10
 */
11
12
namespace PatternSeek\Recycle;
13
14
use Webmozart\PathUtil\Path;
15
16
/**
17
 * Class Recycle
18
 * @package PatternSeek\Recycle
19
 */
20
class Recycle
21
{
22
23
    /**
24
     * @var string
25
     */
26
    private $storageDirectory;
27
28
    /**
29
     * @param $storageDirectory
30
     */
31
    public function __construct( $storageDirectory ){
32
        if( $storageDirectory{-1} !== '/' ){
33
            $storageDirectory .= '/';
34
        }
35
        $this->storageDirectory = $storageDirectory;
36
    }
37
38
    /**
39
     * Move a file or directory to the recycle bin.
40
     *
41
     * @param $path
42
     * @return string The path that $path was renamed to.
43
     * @throws \Exception
44
     */
45
    public function moveToBin( $path ){
46
47
        $path = Path::canonicalize( $path );
48
49
        $this->pathSafetyCheck( $path );
50
        
51
        $date = new \DateTimeImmutable("today");
52
        $dateStr = $date->format("c");
53
        $unique = ( (string) microtime(true) ) .'-'. ( (string) rand(0, 100000 ) );
54
        $basename = basename( $path );
55
        
56
        $todayDir = $this->storageDirectory . $dateStr;
57
        $finalRestingPlaceDir = "{$todayDir}/{$unique}";
58
        $finalRestingPlace = "{$finalRestingPlaceDir}/{$basename}";
59
60
        $this->ensureDirUsable( $this->storageDirectory );
61
        $this->ensureDirUsable( $todayDir );
62
        $this->ensureDirUsable( $finalRestingPlaceDir );
63
        
64
        // Would use PHP's rename but... it doesn't always work
65
        // when moving a directory to another device.
66
        exec( "mv {$path} {$finalRestingPlace}" );
67
        return $finalRestingPlace;
68
    }
69
70
    /**
71
     * Check that directory exists or is creatable and is writable.
72
     * @param $dir
73
     * @throws \Exception
74
     */
75
    private function ensureDirUsable( $dir )
76
    {
77
        if (!file_exists( $dir )) {
78
            mkdir( $dir );
79
            if (!file_exists( $dir )) {
80
                throw new \Exception( "{$dir} does not exist and could not be created." );
81
            }
82
        }
83
        
84
        $this->pathSafetyCheck( $dir );
85
        
86
        $tmpFilename = tempnam( $dir, "___" );
87
        if (!file_exists( $tmpFilename )) {
88
            throw new \Exception( "{$dir} does not appear to be writable." );
89
        }
90
        unlink( $tmpFilename );
91
    }
92
93
    /**
94
     * Empty the bin keeping $keepDays worth of items.
95
     * Note that $keepDays = 1 means that files recycled by scripts which moved
96
     * files after 00:00 (default timezone) today will be kept. It does not keep 24 hours of files.
97
     * 
98
     * Note that items from the future will also be deleted. Only items matching the specific days
99
     * from today into the past will be kept.
100
     * 
101
     * @param int $keepDays 1 = today (back to 00:00 from now).
102
     */
103
    public function emptyBin( $keepDays = 1 ){
104
105
        $items = $this->readDir( $this->storageDirectory );
106
        // Items exclude parent directory!
107
        $toDelete = $this->generateDeletionList( $keepDays, $items );
108
109
        if( count( $toDelete ) > 0 ){
110
            foreach( $toDelete as $itemToDelete){
111
                $fullPathToDelete = "{$this->storageDirectory}/{$itemToDelete}";
112
                $this->pathSafetyCheck( $fullPathToDelete );
113
                exec( "rm -rf {$fullPathToDelete}" );
114
            }
115
        }
116
    }
117
118
    /**
119
     * Return all the filenames in a directory, excluding . and ..
120
     * 
121
     * @param $directoryPath
122
     * @return array
123
     * @throws \Exception
124
     */
125
    private function readDir( $directoryPath )
126
    {
127
        $ret = [];
128
        $this->ensureDirUsable( $directoryPath );
129
        $d = dir( $directoryPath );
130
        while( false !== ( $entry = $d->read() ) ){
131
            if( $entry === '.' || $entry === '..' ){
132
                continue;
133
            }
134
            $ret[] = $entry;
135
        }
136
        $d->close();
137
        return $ret;
138
    }
139
140
    /**
141
     * Given a set of filenames, not including their parent directory, and $keepDays which works
142
     * the same as the $keepDays argument for emptyBin(), determine which entries should be
143
     * deleted. 
144
     * @param $keepDays
145
     * @param $items
146
     * @return array
147
     */
148
    private function generateDeletionList( $keepDays, $items )
149
    {
150
        $keepStrings = [];
151
        if( $keepDays > 0 ){
152
            for( $day = 0; $day < $keepDays; $day++ ){
153
                $dateTmp = new \DateTimeImmutable("today");
154
                $dateTmp = $dateTmp->modify( "-{$day} days" );
155
                $keepStrings[] = $dateTmp->format("c");
156
            }
157
        }
158
        
159
        $toDelete = [];
160
        foreach( $items as $entry ){
161
            // In exclusion list?
162
            if( in_array( $entry, $keepStrings, true ) ){
163
                continue;
164
            }
165
            // Is a valid ISO 8601 date?
166
            if( preg_match(
167
                    '/^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/',
168
                    $entry) 
169
            ){
170
                $toDelete[] = $entry;
171
            }
172
        }
173
        return $toDelete;
174
    }
175
176
    /**
177
     * Do some basic sanity checks on a path
178
     * 
179
     * @param $path
180
     * @throws \Exception
181
     */
182
    private function pathSafetyCheck( $path )
183
    {
184
        // Duplication is intentional for belt-and-braces approach
185
        $path = Path::canonicalize( $path );
186
        if( ! file_exists( $path ) ){
187
            throw new \Exception( "{$path} doesn't exist." );
188
        }
189
        if( is_dir( $path ) ){
190
            // Path::canonicalize() leaves no trailing slash.
191
            $path .= '/';
192
            if( mb_substr_count( $path, '/', "UTF-8" ) < 3 ){
193
                throw new \Exception("Can't use root level directories as recycle area or move or delete them: {$path}");
194
            }
195
        }
196
    }
197
198
}
199