CKEditorInstaller::download()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 20
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 0
Metric Value
cc 3
eloc 10
nc 3
nop 1
dl 0
loc 20
ccs 0
cts 10
cp 0
crap 12
rs 9.9332
c 0
b 0
f 0
1
<?php
2
3
/*
4
 * This file is part of the FOSCKEditor Bundle.
5
 *
6
 * (c) 2018 - present  Friends of Symfony
7
 * (c) 2009 - 2017     Eric GELOEN <[email protected]>
8
 *
9
 * For the full copyright and license information, please read the LICENSE
10
 * file that was distributed with this source code.
11
 */
12
13
namespace FOS\CKEditorBundle\Installer;
14
15
use FOS\CKEditorBundle\Exception\BadProxyUrlException;
16
use Symfony\Component\OptionsResolver\Options;
17
use Symfony\Component\OptionsResolver\OptionsResolver;
18
19
/**
20
 * @author GeLo <[email protected]>
21
 */
22
final class CKEditorInstaller
23
{
24
    const RELEASE_BASIC = 'basic';
25
26
    const RELEASE_FULL = 'full';
27
28
    const RELEASE_STANDARD = 'standard';
29
30
    const RELEASE_CUSTOM = 'custom';
31
32
    const VERSION_LATEST = 'latest';
33
34
    const CLEAR_DROP = 'drop';
35
36
    const CLEAR_KEEP = 'keep';
37
38
    const CLEAR_SKIP = 'skip';
39
40
    const NOTIFY_CLEAR = 'clear';
41
42
    const NOTIFY_CLEAR_ARCHIVE = 'clear-archive';
43
44
    const NOTIFY_CLEAR_COMPLETE = 'clear-complete';
45
46
    const NOTIFY_CLEAR_PROGRESS = 'clear-progress';
47
48
    const NOTIFY_CLEAR_QUESTION = 'clear-question';
49
50
    const NOTIFY_CLEAR_SIZE = 'clear-size';
51
52
    const NOTIFY_DOWNLOAD = 'download';
53
54
    const NOTIFY_DOWNLOAD_COMPLETE = 'download-complete';
55
56
    const NOTIFY_DOWNLOAD_PROGRESS = 'download-progress';
57
58
    const NOTIFY_DOWNLOAD_SIZE = 'download-size';
59
60
    const NOTIFY_EXTRACT = 'extract';
61
62
    const NOTIFY_EXTRACT_COMPLETE = 'extract-complete';
63
64
    const NOTIFY_EXTRACT_PROGRESS = 'extract-progress';
65
66
    const NOTIFY_EXTRACT_SIZE = 'extract-size';
67
68
    /**
69
     * @var string
70
     */
71
    private static $archive = 'https://github.com/ckeditor/ckeditor-releases/archive/%s/%s.zip';
72
73
    /**
74
     * @var string
75
     */
76
    private static $customBuildArchive = 'https://ckeditor.com/cke4/builder/download/%s';
77
78
    /**
79
     * @var OptionsResolver
80
     */
81
    private $resolver;
82
83
    public function __construct(array $options = [])
84
    {
85
        $this->resolver = (new OptionsResolver())
86
            ->setDefaults(array_merge([
87
                'clear' => null,
88
                'excludes' => ['samples'],
89
                'notifier' => null,
90
                'path' => dirname(__DIR__).'/Resources/public',
91
                'release' => self::RELEASE_FULL,
92
                'custom_build_id' => null,
93
                'version' => self::VERSION_LATEST,
94
            ], $options))
95
            ->setAllowedTypes('excludes', 'array')
96
            ->setAllowedTypes('notifier', ['null', 'callable'])
97
            ->setAllowedTypes('path', 'string')
98
            ->setAllowedTypes('custom_build_id', ['null', 'string'])
99
            ->setAllowedTypes('version', 'string')
100
            ->setAllowedValues('clear', [self::CLEAR_DROP, self::CLEAR_KEEP, self::CLEAR_SKIP, null])
101
            ->setAllowedValues('release', [self::RELEASE_BASIC, self::RELEASE_FULL, self::RELEASE_STANDARD, self::RELEASE_CUSTOM])
102
            ->setNormalizer('path', function (Options $options, $path) {
103
                return rtrim($path, '/');
104
            });
105
    }
106
107
    public function install(array $options = []): bool
108
    {
109
        $options = $this->resolver->resolve($options);
110
111
        if (self::CLEAR_SKIP === $this->clear($options)) {
112
            return false;
113
        }
114
115
        $this->extract($this->download($options), $options);
116
117
        return true;
118
    }
119
120
    private function clear(array $options): string
121
    {
122
        if (!file_exists($options['path'].'/ckeditor.js')) {
123
            return self::CLEAR_DROP;
124
        }
125
126
        if (null === $options['clear'] && null !== $options['notifier']) {
127
            $options['clear'] = $this->notify($options['notifier'], self::NOTIFY_CLEAR, $options['path']);
128
        }
129
130
        if (null === $options['clear']) {
131
            $options['clear'] = self::CLEAR_SKIP;
132
        }
133
134
        if (self::CLEAR_DROP === $options['clear']) {
135
            $files = new \RecursiveIteratorIterator(
136
                new \RecursiveDirectoryIterator($options['path'], \RecursiveDirectoryIterator::SKIP_DOTS),
137
                \RecursiveIteratorIterator::CHILD_FIRST
138
            );
139
140
            $this->notify($options['notifier'], self::NOTIFY_CLEAR_SIZE, iterator_count($files));
141
142
            foreach ($files as $file) {
143
                $filePath = $file->getRealPath();
144
                $this->notify($options['notifier'], self::NOTIFY_CLEAR_PROGRESS, $filePath);
145
146
                if ($dir = $file->isDir()) {
147
                    $success = @rmdir($filePath);
148
                } else {
149
                    $success = @unlink($filePath);
150
                }
151
152
                if (!$success) {
153
                    throw $this->createException(sprintf(
154
                        'Unable to remove the %s "%s".',
155
                        $dir ? 'directory' : 'file',
156
                        $filePath
157
                    ));
158
                }
159
            }
160
161
            $this->notify($options['notifier'], self::NOTIFY_CLEAR_COMPLETE);
162
        }
163
164
        return $options['clear'];
165
    }
166
167
    private function download(array $options): string
168
    {
169
        $url = $this->getDownloadUrl($options);
170
        $this->notify($options['notifier'], self::NOTIFY_DOWNLOAD, $url);
171
172
        $zip = @file_get_contents($url, false, $this->createStreamContext($options['notifier']));
173
174
        if (false === $zip) {
175
            throw $this->createException(sprintf('Unable to download CKEditor ZIP archive from "%s".', $url));
176
        }
177
178
        $path = (string) tempnam(sys_get_temp_dir(), 'ckeditor-'.$options['release'].'-'.$options['version'].'.zip');
179
180
        if (!@file_put_contents($path, $zip)) {
181
            throw $this->createException(sprintf('Unable to write CKEditor ZIP archive to "%s".', $path));
182
        }
183
184
        $this->notify($options['notifier'], self::NOTIFY_DOWNLOAD_COMPLETE, $path);
185
186
        return $path;
187
    }
188
189
    private function getDownloadUrl(array $options): string
190
    {
191
        if (self::RELEASE_CUSTOM !== $options['release']) {
192
            return sprintf(self::$archive, $options['release'], $options['version']);
193
        }
194
195
        if (null === $options['custom_build_id']) {
196
            throw $this->createException('Unable to download CKEditor ZIP archive. Custom build ID is not specified.');
197
        }
198
199
        if (self::VERSION_LATEST !== $options['version']) {
200
            throw $this->createException('Unable to download CKEditor ZIP archive. Specifying version for custom build is not supported.');
201
        }
202
203
        return sprintf(self::$customBuildArchive, $options['custom_build_id']);
204
    }
205
206
    /**
207
     * @return resource
208
     */
209
    private function createStreamContext(callable $notifier = null)
210
    {
211
        $context = [];
212
        $proxy = getenv('https_proxy') ?: getenv('http_proxy');
213
214
        if ($proxy) {
215
            $proxyUrl = parse_url($proxy);
216
217
            if (false === $proxyUrl || !isset($proxyUrl['host']) || !isset($proxyUrl['port'])) {
218
                throw BadProxyUrlException::fromEnvUrl($proxy);
219
            }
220
221
            $context['http'] = [
222
                'proxy' => 'tcp://'.$proxyUrl['host'].':'.$proxyUrl['port'],
223
                'request_fulluri' => (bool) getenv('https_proxy_request_fulluri') ?:
224
                    getenv('http_proxy_request_fulluri'),
225
            ];
226
        }
227
228
        return stream_context_create($context, [
229
            'notification' => function (
230
                $code,
231
                $severity,
232
                $message,
233
                $messageCode,
234
                $transferred,
235
                $size
236
            ) use ($notifier) {
237
                if (null === $notifier) {
238
                    return;
239
                }
240
241
                switch ($code) {
242
                    case STREAM_NOTIFY_FILE_SIZE_IS:
243
                        $this->notify($notifier, self::NOTIFY_DOWNLOAD_SIZE, $size);
244
245
                        break;
246
247
                    case STREAM_NOTIFY_PROGRESS:
248
                        $this->notify($notifier, self::NOTIFY_DOWNLOAD_PROGRESS, $transferred);
249
250
                        break;
251
                }
252
            },
253
        ]);
254
    }
255
256
    private function extract(string $path, array $options): void
257
    {
258
        $this->notify($options['notifier'], self::NOTIFY_EXTRACT, $options['path']);
259
260
        $zip = new \ZipArchive();
261
        $zip->open($path);
262
263
        $this->notify($options['notifier'], self::NOTIFY_EXTRACT_SIZE, $zip->numFiles);
264
265
        if (self::RELEASE_CUSTOM === $options['release']) {
266
            $offset = 9;
267
        } else {
268
            $offset = 20 + strlen($options['release']) + strlen($options['version']);
269
        }
270
271
        for ($i = 0; $i < $zip->numFiles; ++$i) {
272
            $filename = $zip->getNameIndex($i);
273
            $isDirectory = ('/' === substr($filename, -1, 1));
274
275
            if (!$isDirectory) {
276
                $this->extractFile($filename, substr($filename, $offset), $path, $options);
277
            }
278
        }
279
280
        $zip->close();
281
282
        $this->notify($options['notifier'], self::NOTIFY_EXTRACT_COMPLETE);
283
        $this->notify($options['notifier'], self::NOTIFY_CLEAR_ARCHIVE, $path);
284
285
        if (!@unlink($path)) {
286
            throw $this->createException(sprintf('Unable to remove the CKEditor ZIP archive "%s".', $path));
287
        }
288
    }
289
290
    private function extractFile(string $file, string $rewrite, string $origin, array $options): void
291
    {
292
        $this->notify($options['notifier'], self::NOTIFY_EXTRACT_PROGRESS, $rewrite);
293
294
        $from = 'zip://'.$origin.'#'.$file;
295
        $to = $options['path'].'/'.$rewrite;
296
297
        foreach ($options['excludes'] as $exclude) {
298
            if (0 === strpos($rewrite, $exclude)) {
299
                return;
300
            }
301
        }
302
303
        $targetDirectory = dirname($to);
304
        if (!is_dir($targetDirectory) && !@mkdir($targetDirectory, 0777, true)) {
305
            throw $this->createException(sprintf('Unable to create the directory "%s".', $targetDirectory));
306
        }
307
308
        if (!@copy($from, $to)) {
309
            throw $this->createException(sprintf('Unable to extract the file "%s" to "%s".', $file, $to));
310
        }
311
    }
312
313
    /**
314
     * @param mixed $data
315
     *
316
     * @return mixed
317
     */
318
    private function notify(callable $notifier = null, string $type, $data = null)
319
    {
320
        if (null !== $notifier) {
321
            return $notifier($type, $data);
322
        }
323
    }
324
325
    private function createException(string $message): \RuntimeException
326
    {
327
        $error = error_get_last();
328
329
        if (isset($error['message'])) {
330
            $message .= sprintf(' (%s)', $error['message']);
331
        }
332
333
        return new \RuntimeException($message);
334
    }
335
}
336