xref: /dokuwiki/lib/plugins/extension/Installer.php (revision 7c9966a55ca5608f06b38319bc27b6b621cba7d1)
1cf2dcf1bSAndreas Gohr<?php
2cf2dcf1bSAndreas Gohr
3cf2dcf1bSAndreas Gohrnamespace dokuwiki\plugin\extension;
4cf2dcf1bSAndreas Gohr
5cf2dcf1bSAndreas Gohruse dokuwiki\HTTP\DokuHTTPClient;
6cf2dcf1bSAndreas Gohruse dokuwiki\Utf8\PhpString;
7cf2dcf1bSAndreas Gohruse RecursiveDirectoryIterator;
8cf2dcf1bSAndreas Gohruse RecursiveIteratorIterator;
9cf2dcf1bSAndreas Gohruse splitbrain\PHPArchive\ArchiveCorruptedException;
10cf2dcf1bSAndreas Gohruse splitbrain\PHPArchive\ArchiveIllegalCompressionException;
11cf2dcf1bSAndreas Gohruse splitbrain\PHPArchive\ArchiveIOException;
12cf2dcf1bSAndreas Gohruse splitbrain\PHPArchive\Tar;
13cf2dcf1bSAndreas Gohruse splitbrain\PHPArchive\Zip;
14cf2dcf1bSAndreas Gohr
15cf2dcf1bSAndreas Gohr/**
16cf2dcf1bSAndreas Gohr * Install and deinstall extensions
17cf2dcf1bSAndreas Gohr *
18cf2dcf1bSAndreas Gohr * This manages all the file operations and downloads needed to install an extension.
19cf2dcf1bSAndreas Gohr */
20cf2dcf1bSAndreas Gohrclass Installer
21cf2dcf1bSAndreas Gohr{
22cf2dcf1bSAndreas Gohr    /** @var string[] a list of temporary directories used during this installation */
23cf2dcf1bSAndreas Gohr    protected array $temporary = [];
24cf2dcf1bSAndreas Gohr
25cf2dcf1bSAndreas Gohr    /** @var bool if changes have been made that require a cache purge */
26cf2dcf1bSAndreas Gohr    protected $isDirty = false;
27cf2dcf1bSAndreas Gohr
28cf2dcf1bSAndreas Gohr    /** @var bool Replace existing files? */
29cf2dcf1bSAndreas Gohr    protected $overwrite = false;
30cf2dcf1bSAndreas Gohr
31cf2dcf1bSAndreas Gohr    /** @var string The last used URL to install an extension */
32cf2dcf1bSAndreas Gohr    protected $sourceUrl = '';
33cf2dcf1bSAndreas Gohr
34cf2dcf1bSAndreas Gohr    /**
35cf2dcf1bSAndreas Gohr     * Initialize a new extension installer
36cf2dcf1bSAndreas Gohr     *
37cf2dcf1bSAndreas Gohr     * @param bool $overwrite
38cf2dcf1bSAndreas Gohr     */
39cf2dcf1bSAndreas Gohr    public function __construct($overwrite = false)
40cf2dcf1bSAndreas Gohr    {
41cf2dcf1bSAndreas Gohr        $this->overwrite = $overwrite;
42cf2dcf1bSAndreas Gohr    }
43cf2dcf1bSAndreas Gohr
44cf2dcf1bSAndreas Gohr    /**
45cf2dcf1bSAndreas Gohr     * Destructor
46cf2dcf1bSAndreas Gohr     *
47cf2dcf1bSAndreas Gohr     * deletes any dangling temporary directories
48cf2dcf1bSAndreas Gohr     */
49cf2dcf1bSAndreas Gohr    public function __destruct()
50cf2dcf1bSAndreas Gohr    {
51cf2dcf1bSAndreas Gohr        $this->cleanUp();
52cf2dcf1bSAndreas Gohr    }
53cf2dcf1bSAndreas Gohr
54cf2dcf1bSAndreas Gohr    /**
55cf2dcf1bSAndreas Gohr     * Install extensions from a given URL
56cf2dcf1bSAndreas Gohr     *
57cf2dcf1bSAndreas Gohr     * @param string $url the URL to the archive
58cf2dcf1bSAndreas Gohr     * @param null $base the base directory name to use
59cf2dcf1bSAndreas Gohr     * @throws Exception
60cf2dcf1bSAndreas Gohr     */
61cf2dcf1bSAndreas Gohr    public function installFromUrl($url, $base = null)
62cf2dcf1bSAndreas Gohr    {
63cf2dcf1bSAndreas Gohr        $this->sourceUrl = $url;
64cf2dcf1bSAndreas Gohr        $archive = $this->downloadArchive($url);
65cf2dcf1bSAndreas Gohr        $this->installFromArchive(
66cf2dcf1bSAndreas Gohr            $archive,
67cf2dcf1bSAndreas Gohr            $base
68cf2dcf1bSAndreas Gohr        );
69cf2dcf1bSAndreas Gohr    }
70cf2dcf1bSAndreas Gohr
71cf2dcf1bSAndreas Gohr    /**
72cf2dcf1bSAndreas Gohr     * Install extensions from a user upload
73cf2dcf1bSAndreas Gohr     *
74cf2dcf1bSAndreas Gohr     * @param string $field name of the upload file
75cf2dcf1bSAndreas Gohr     * @throws Exception
76cf2dcf1bSAndreas Gohr     */
77cf2dcf1bSAndreas Gohr    public function installFromUpload($field)
78cf2dcf1bSAndreas Gohr    {
79cf2dcf1bSAndreas Gohr        $this->sourceUrl = '';
80cf2dcf1bSAndreas Gohr        if ($_FILES[$field]['error']) {
81cf2dcf1bSAndreas Gohr            throw new Exception('msg_upload_failed', [$_FILES[$field]['error']]);
82cf2dcf1bSAndreas Gohr        }
83cf2dcf1bSAndreas Gohr
84cf2dcf1bSAndreas Gohr        $tmp = $this->mkTmpDir();
85cf2dcf1bSAndreas Gohr        if (!move_uploaded_file($_FILES[$field]['tmp_name'], "$tmp/upload.archive")) {
86cf2dcf1bSAndreas Gohr            throw new Exception('msg_upload_failed', ['move failed']);
87cf2dcf1bSAndreas Gohr        }
88cf2dcf1bSAndreas Gohr        $this->installFromArchive(
89cf2dcf1bSAndreas Gohr            "$tmp/upload.archive",
90cf2dcf1bSAndreas Gohr            $this->fileToBase($_FILES[$field]['name']),
91cf2dcf1bSAndreas Gohr        );
92cf2dcf1bSAndreas Gohr    }
93cf2dcf1bSAndreas Gohr
94cf2dcf1bSAndreas Gohr    /**
95cf2dcf1bSAndreas Gohr     * Install extensions from an archive
96cf2dcf1bSAndreas Gohr     *
97cf2dcf1bSAndreas Gohr     * The archive is extracted to a temporary directory and then the contained extensions are installed.
98cf2dcf1bSAndreas Gohr     * This is is the ultimate installation procedure and all other install methods will end up here.
99cf2dcf1bSAndreas Gohr     *
100cf2dcf1bSAndreas Gohr     * @param string $archive the path to the archive
101cf2dcf1bSAndreas Gohr     * @param string $base the base directory name to use
102cf2dcf1bSAndreas Gohr     * @throws Exception
103cf2dcf1bSAndreas Gohr     */
104cf2dcf1bSAndreas Gohr    public function installFromArchive($archive, $base = null)
105cf2dcf1bSAndreas Gohr    {
106cf2dcf1bSAndreas Gohr        if ($base === null) $base = $this->fileToBase($archive);
107cf2dcf1bSAndreas Gohr        $target = $this->mkTmpDir() . '/' . $base;
108cf2dcf1bSAndreas Gohr        $this->extractArchive($archive, $target);
109cf2dcf1bSAndreas Gohr        $extensions = $this->findExtensions($target, $base);
110cf2dcf1bSAndreas Gohr        foreach ($extensions as $extension) {
111cf2dcf1bSAndreas Gohr            if ($extension->isInstalled() && !$this->overwrite) {
112cf2dcf1bSAndreas Gohr                // FIXME remember skipped extensions
113cf2dcf1bSAndreas Gohr                continue;
114cf2dcf1bSAndreas Gohr            }
115cf2dcf1bSAndreas Gohr
116cf2dcf1bSAndreas Gohr            $this->dircopy(
117cf2dcf1bSAndreas Gohr                $extension->getCurrentDir(),
118cf2dcf1bSAndreas Gohr                $extension->getInstallDir()
119cf2dcf1bSAndreas Gohr            );
120cf2dcf1bSAndreas Gohr            $this->isDirty = true;
121*7c9966a5SAndreas Gohr            $extension->getManager()->storeUpdate($this->sourceUrl);
122cf2dcf1bSAndreas Gohr            $this->removeDeletedFiles($extension);
123cf2dcf1bSAndreas Gohr
124cf2dcf1bSAndreas Gohr            // FIXME remember installed extensions and if it was an update or new install
125cf2dcf1bSAndreas Gohr            // FIXME queue dependencies for installation
126cf2dcf1bSAndreas Gohr        }
127cf2dcf1bSAndreas Gohr
128cf2dcf1bSAndreas Gohr        // FIXME process dependency queue
129cf2dcf1bSAndreas Gohr
130cf2dcf1bSAndreas Gohr        $this->cleanUp();
131cf2dcf1bSAndreas Gohr    }
132cf2dcf1bSAndreas Gohr
133cf2dcf1bSAndreas Gohr    /**
134cf2dcf1bSAndreas Gohr     * Uninstall an extension
135cf2dcf1bSAndreas Gohr     *
136cf2dcf1bSAndreas Gohr     * @param Extension $extension
137cf2dcf1bSAndreas Gohr     * @throws Exception
138cf2dcf1bSAndreas Gohr     */
139cf2dcf1bSAndreas Gohr    public function uninstall(Extension $extension)
140cf2dcf1bSAndreas Gohr    {
141cf2dcf1bSAndreas Gohr        // FIXME check if dependencies are still needed
142cf2dcf1bSAndreas Gohr
143cf2dcf1bSAndreas Gohr        if($extension->isProtected()) {
144cf2dcf1bSAndreas Gohr            throw new Exception('error_uninstall_protected', [$extension->getId()]);
145cf2dcf1bSAndreas Gohr        }
146cf2dcf1bSAndreas Gohr
147cf2dcf1bSAndreas Gohr        if (!io_rmdir($extension->getInstallDir(), true)) {
148cf2dcf1bSAndreas Gohr            throw new Exception('msg_delete_failed', [$extension->getId()]);
149cf2dcf1bSAndreas Gohr        }
150cf2dcf1bSAndreas Gohr        self::purgeCache();
151cf2dcf1bSAndreas Gohr    }
152cf2dcf1bSAndreas Gohr
153cf2dcf1bSAndreas Gohr    /**
154cf2dcf1bSAndreas Gohr     * Download an archive to a protected path
155cf2dcf1bSAndreas Gohr     *
156cf2dcf1bSAndreas Gohr     * @param string $url The url to get the archive from
157cf2dcf1bSAndreas Gohr     * @return string The path where the archive was saved
158cf2dcf1bSAndreas Gohr     * @throws Exception
159cf2dcf1bSAndreas Gohr     */
160cf2dcf1bSAndreas Gohr    public function downloadArchive($url)
161cf2dcf1bSAndreas Gohr    {
162cf2dcf1bSAndreas Gohr        // check the url
163cf2dcf1bSAndreas Gohr        if (!preg_match('/https?:\/\//i', $url)) {
164cf2dcf1bSAndreas Gohr            throw new Exception('error_badurl');
165cf2dcf1bSAndreas Gohr        }
166cf2dcf1bSAndreas Gohr
167cf2dcf1bSAndreas Gohr        // try to get the file from the path (used as plugin name fallback)
168cf2dcf1bSAndreas Gohr        $file = parse_url($url, PHP_URL_PATH);
169cf2dcf1bSAndreas Gohr        $file = $file ? PhpString::basename($file) : md5($url);
170cf2dcf1bSAndreas Gohr
171cf2dcf1bSAndreas Gohr        // download
172cf2dcf1bSAndreas Gohr        $http = new DokuHTTPClient();
173cf2dcf1bSAndreas Gohr        $http->max_bodysize = 0;
174cf2dcf1bSAndreas Gohr        $http->timeout = 25; //max. 25 sec
175cf2dcf1bSAndreas Gohr        $http->keep_alive = false; // we do single ops here, no need for keep-alive
176cf2dcf1bSAndreas Gohr        $http->agent = 'DokuWiki HTTP Client (Extension Manager)';
177cf2dcf1bSAndreas Gohr
178cf2dcf1bSAndreas Gohr        $data = $http->get($url);
179cf2dcf1bSAndreas Gohr        if ($data === false) throw new Exception('error_download', [$url, $http->error, $http->status]);
180cf2dcf1bSAndreas Gohr
181cf2dcf1bSAndreas Gohr        // get filename from headers
182cf2dcf1bSAndreas Gohr        if (preg_match(
183cf2dcf1bSAndreas Gohr            '/attachment;\s*filename\s*=\s*"([^"]*)"/i',
184cf2dcf1bSAndreas Gohr            (string)($http->resp_headers['content-disposition'] ?? ''),
185cf2dcf1bSAndreas Gohr            $match
186cf2dcf1bSAndreas Gohr        )) {
187cf2dcf1bSAndreas Gohr            $file = PhpString::basename($match[1]);
188cf2dcf1bSAndreas Gohr        }
189cf2dcf1bSAndreas Gohr
190cf2dcf1bSAndreas Gohr        // clean up filename
191cf2dcf1bSAndreas Gohr        $file = $this->fileToBase($file);
192cf2dcf1bSAndreas Gohr
193cf2dcf1bSAndreas Gohr        // create tmp directory for download
194cf2dcf1bSAndreas Gohr        $tmp = $this->mkTmpDir();
195cf2dcf1bSAndreas Gohr
196cf2dcf1bSAndreas Gohr        // save the file
197cf2dcf1bSAndreas Gohr        if (@file_put_contents("$tmp/$file", $data) === false) {
198cf2dcf1bSAndreas Gohr            throw new Exception('error_save');
199cf2dcf1bSAndreas Gohr        }
200cf2dcf1bSAndreas Gohr
201cf2dcf1bSAndreas Gohr        return "$tmp/$file";
202cf2dcf1bSAndreas Gohr    }
203cf2dcf1bSAndreas Gohr
204cf2dcf1bSAndreas Gohr
205cf2dcf1bSAndreas Gohr    /**
206cf2dcf1bSAndreas Gohr     * Delete outdated files
207cf2dcf1bSAndreas Gohr     */
208cf2dcf1bSAndreas Gohr    public function removeDeletedFiles(Extension $extension)
209cf2dcf1bSAndreas Gohr    {
210cf2dcf1bSAndreas Gohr        $extensiondir = $extension->getInstallDir();
211cf2dcf1bSAndreas Gohr        $definitionfile = $extensiondir . '/deleted.files';
212cf2dcf1bSAndreas Gohr        if (!file_exists($definitionfile)) return;
213cf2dcf1bSAndreas Gohr
214cf2dcf1bSAndreas Gohr        $list = file($definitionfile);
215cf2dcf1bSAndreas Gohr        foreach ($list as $line) {
216cf2dcf1bSAndreas Gohr            $line = trim(preg_replace('/#.*$/', '', $line));
217cf2dcf1bSAndreas Gohr            $line = str_replace('..', '', $line); // do not run out of the extension directory
218cf2dcf1bSAndreas Gohr            if (!$line) continue;
219cf2dcf1bSAndreas Gohr
220cf2dcf1bSAndreas Gohr            $file = $extensiondir . '/' . $line;
221cf2dcf1bSAndreas Gohr            if (!file_exists($file)) continue;
222cf2dcf1bSAndreas Gohr
223cf2dcf1bSAndreas Gohr            io_rmdir($file, true);
224cf2dcf1bSAndreas Gohr        }
225cf2dcf1bSAndreas Gohr    }
226cf2dcf1bSAndreas Gohr
227cf2dcf1bSAndreas Gohr    public static function purgeCache()
228cf2dcf1bSAndreas Gohr    {
229cf2dcf1bSAndreas Gohr        // expire dokuwiki caches
230cf2dcf1bSAndreas Gohr        // touching local.php expires wiki page, JS and CSS caches
231cf2dcf1bSAndreas Gohr        global $config_cascade;
232cf2dcf1bSAndreas Gohr        @touch(reset($config_cascade['main']['local']));
233cf2dcf1bSAndreas Gohr
234cf2dcf1bSAndreas Gohr        if (function_exists('opcache_reset')) {
235cf2dcf1bSAndreas Gohr            opcache_reset();
236cf2dcf1bSAndreas Gohr        }
237cf2dcf1bSAndreas Gohr    }
238cf2dcf1bSAndreas Gohr
239cf2dcf1bSAndreas Gohr    /**
240cf2dcf1bSAndreas Gohr     * Get a base name from an archive name (we don't trust)
241cf2dcf1bSAndreas Gohr     *
242cf2dcf1bSAndreas Gohr     * @param string $file
243cf2dcf1bSAndreas Gohr     * @return string
244cf2dcf1bSAndreas Gohr     */
245cf2dcf1bSAndreas Gohr    protected function fileToBase($file)
246cf2dcf1bSAndreas Gohr    {
247cf2dcf1bSAndreas Gohr        $base = PhpString::basename($file);
248cf2dcf1bSAndreas Gohr        $base = preg_replace('/\.(tar\.gz|tar\.bz|tar\.bz2|tar|tgz|tbz|zip)$/', '', $base);
249cf2dcf1bSAndreas Gohr        return preg_replace('/\W+/', '', $base);
250cf2dcf1bSAndreas Gohr    }
251cf2dcf1bSAndreas Gohr
252cf2dcf1bSAndreas Gohr    /**
253cf2dcf1bSAndreas Gohr     * Returns a temporary directory
254cf2dcf1bSAndreas Gohr     *
255cf2dcf1bSAndreas Gohr     * The directory is registered for cleanup when the class is destroyed
256cf2dcf1bSAndreas Gohr     *
257cf2dcf1bSAndreas Gohr     * @return string
258cf2dcf1bSAndreas Gohr     * @throws Exception
259cf2dcf1bSAndreas Gohr     */
260cf2dcf1bSAndreas Gohr    protected function mkTmpDir()
261cf2dcf1bSAndreas Gohr    {
262cf2dcf1bSAndreas Gohr        try {
263cf2dcf1bSAndreas Gohr            $dir = io_mktmpdir();
264cf2dcf1bSAndreas Gohr        } catch (\Exception $e) {
265cf2dcf1bSAndreas Gohr            throw new Exception('error_dircreate', [], $e);
266cf2dcf1bSAndreas Gohr        }
267cf2dcf1bSAndreas Gohr        if (!$dir) throw new Exception('error_dircreate');
268cf2dcf1bSAndreas Gohr        $this->temporary[] = $dir;
269cf2dcf1bSAndreas Gohr        return $dir;
270cf2dcf1bSAndreas Gohr    }
271cf2dcf1bSAndreas Gohr
272cf2dcf1bSAndreas Gohr    /**
273cf2dcf1bSAndreas Gohr     * Find all extensions in a given directory
274cf2dcf1bSAndreas Gohr     *
275cf2dcf1bSAndreas Gohr     * This allows us to install extensions from archives that contain multiple extensions and
276cf2dcf1bSAndreas Gohr     * also caters for the fact that archives may or may not contain subdirectories for the extension(s).
277cf2dcf1bSAndreas Gohr     *
278cf2dcf1bSAndreas Gohr     * @param string $dir
279cf2dcf1bSAndreas Gohr     * @return Extension[]
280cf2dcf1bSAndreas Gohr     */
281cf2dcf1bSAndreas Gohr    protected function findExtensions($dir, $base = null)
282cf2dcf1bSAndreas Gohr    {
283cf2dcf1bSAndreas Gohr        // first check for plugin.info.txt or template.info.txt
284cf2dcf1bSAndreas Gohr        $extensions = [];
285cf2dcf1bSAndreas Gohr        $iterator = new RecursiveDirectoryIterator($dir);
286cf2dcf1bSAndreas Gohr        foreach (new RecursiveIteratorIterator($iterator) as $file) {
287cf2dcf1bSAndreas Gohr            if (
288cf2dcf1bSAndreas Gohr                $file->getFilename() === 'plugin.info.txt' ||
289cf2dcf1bSAndreas Gohr                $file->getFilename() === 'template.info.txt'
290cf2dcf1bSAndreas Gohr            ) {
291cf2dcf1bSAndreas Gohr                $extensions = Extension::createFromDirectory($file->getPath());
292cf2dcf1bSAndreas Gohr            }
293cf2dcf1bSAndreas Gohr        }
294cf2dcf1bSAndreas Gohr        if ($extensions) return $extensions;
295cf2dcf1bSAndreas Gohr
296cf2dcf1bSAndreas Gohr        // still nothing? we assume this to be a single extension that is either
297cf2dcf1bSAndreas Gohr        // directly in the given directory or in single subdirectory
298cf2dcf1bSAndreas Gohr        $base = $base ?? PhpString::basename($dir);
299cf2dcf1bSAndreas Gohr        $files = glob($dir . '/*');
300cf2dcf1bSAndreas Gohr        if (count($files) === 1 && is_dir($files[0])) {
301cf2dcf1bSAndreas Gohr            $dir = $files[0];
302cf2dcf1bSAndreas Gohr        }
303cf2dcf1bSAndreas Gohr        return [Extension::createFromDirectory($dir, null, $base)];
304cf2dcf1bSAndreas Gohr    }
305cf2dcf1bSAndreas Gohr
306cf2dcf1bSAndreas Gohr    /**
307cf2dcf1bSAndreas Gohr     * Extract the given archive to the given target directory
308cf2dcf1bSAndreas Gohr     *
309cf2dcf1bSAndreas Gohr     * Auto-guesses the archive type
310cf2dcf1bSAndreas Gohr     * @throws Exception
311cf2dcf1bSAndreas Gohr     */
312cf2dcf1bSAndreas Gohr    protected function extractArchive($archive, $target)
313cf2dcf1bSAndreas Gohr    {
314cf2dcf1bSAndreas Gohr        $fh = fopen($archive, 'rb');
315cf2dcf1bSAndreas Gohr        if (!$fh) throw new Exception('error_archive_read', [$archive]);
316cf2dcf1bSAndreas Gohr        $magic = fread($fh, 5);
317cf2dcf1bSAndreas Gohr        fclose($fh);
318cf2dcf1bSAndreas Gohr
319cf2dcf1bSAndreas Gohr        if (strpos($magic, "\x50\x4b\x03\x04") === 0) {
320cf2dcf1bSAndreas Gohr            $archiver = new Zip();
321cf2dcf1bSAndreas Gohr        } else {
322cf2dcf1bSAndreas Gohr            $archiver = new Tar();
323cf2dcf1bSAndreas Gohr        }
324cf2dcf1bSAndreas Gohr        try {
325cf2dcf1bSAndreas Gohr            $archiver->open($archive);
326cf2dcf1bSAndreas Gohr            $archiver->extract($target);
327cf2dcf1bSAndreas Gohr        } catch (ArchiveIOException|ArchiveCorruptedException|ArchiveIllegalCompressionException $e) {
328cf2dcf1bSAndreas Gohr            throw new Exception('error_archive_extract', [$archive, $e->getMessage()], $e);
329cf2dcf1bSAndreas Gohr        }
330cf2dcf1bSAndreas Gohr    }
331cf2dcf1bSAndreas Gohr
332cf2dcf1bSAndreas Gohr    /**
333cf2dcf1bSAndreas Gohr     * Copy with recursive sub-directory support
334cf2dcf1bSAndreas Gohr     *
335cf2dcf1bSAndreas Gohr     * @param string $src filename path to file
336cf2dcf1bSAndreas Gohr     * @param string $dst filename path to file
337cf2dcf1bSAndreas Gohr     * @throws Exception
338cf2dcf1bSAndreas Gohr     */
339cf2dcf1bSAndreas Gohr    protected function dircopy($src, $dst)
340cf2dcf1bSAndreas Gohr    {
341cf2dcf1bSAndreas Gohr        global $conf;
342cf2dcf1bSAndreas Gohr
343cf2dcf1bSAndreas Gohr        if (is_dir($src)) {
344cf2dcf1bSAndreas Gohr            if (!$dh = @opendir($src)) {
345cf2dcf1bSAndreas Gohr                throw new Exception('error_copy_read', [$src]);
346cf2dcf1bSAndreas Gohr            }
347cf2dcf1bSAndreas Gohr
348cf2dcf1bSAndreas Gohr            if (io_mkdir_p($dst)) {
349cf2dcf1bSAndreas Gohr                while (false !== ($f = readdir($dh))) {
350cf2dcf1bSAndreas Gohr                    if ($f == '..' || $f == '.') continue;
351cf2dcf1bSAndreas Gohr                    $this->dircopy("$src/$f", "$dst/$f");
352cf2dcf1bSAndreas Gohr                }
353cf2dcf1bSAndreas Gohr            } else {
354cf2dcf1bSAndreas Gohr                throw new Exception('error_copy_mkdir', [$dst]);
355cf2dcf1bSAndreas Gohr            }
356cf2dcf1bSAndreas Gohr
357cf2dcf1bSAndreas Gohr            closedir($dh);
358cf2dcf1bSAndreas Gohr        } else {
359cf2dcf1bSAndreas Gohr            $existed = file_exists($dst);
360cf2dcf1bSAndreas Gohr
361cf2dcf1bSAndreas Gohr            if (!@copy($src, $dst)) {
362cf2dcf1bSAndreas Gohr                throw new Exception('error_copy_copy', [$src, $dst]);
363cf2dcf1bSAndreas Gohr            }
364cf2dcf1bSAndreas Gohr            if (!$existed && $conf['fperm']) chmod($dst, $conf['fperm']);
365cf2dcf1bSAndreas Gohr            @touch($dst, filemtime($src));
366cf2dcf1bSAndreas Gohr        }
367cf2dcf1bSAndreas Gohr    }
368cf2dcf1bSAndreas Gohr
369cf2dcf1bSAndreas Gohr    /**
370cf2dcf1bSAndreas Gohr     * Clean up all temporary directories and reset caches
371cf2dcf1bSAndreas Gohr     */
372cf2dcf1bSAndreas Gohr    protected function cleanUp()
373cf2dcf1bSAndreas Gohr    {
374cf2dcf1bSAndreas Gohr        foreach ($this->temporary as $dir) {
375cf2dcf1bSAndreas Gohr            io_rmdir($dir, true);
376cf2dcf1bSAndreas Gohr        }
377cf2dcf1bSAndreas Gohr        $this->temporary = [];
378cf2dcf1bSAndreas Gohr
379cf2dcf1bSAndreas Gohr        if ($this->isDirty) {
380cf2dcf1bSAndreas Gohr            self::purgeCache();
381cf2dcf1bSAndreas Gohr            $this->isDirty = false;
382cf2dcf1bSAndreas Gohr        }
383cf2dcf1bSAndreas Gohr    }
384cf2dcf1bSAndreas Gohr}
385