ci4

Форк
0
/
Publisher.php 
516 строк · 14.4 Кб
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\Publisher;
15

16
use CodeIgniter\Autoloader\FileLocatorInterface;
17
use CodeIgniter\Files\FileCollection;
18
use CodeIgniter\HTTP\URI;
19
use CodeIgniter\Publisher\Exceptions\PublisherException;
20
use Config\Publisher as PublisherConfig;
21
use RuntimeException;
22
use Throwable;
23

24
/**
25
 * Publishers read in file paths from a variety of sources and copy
26
 * the files out to different destinations. This class acts both as
27
 * a base for individual publication directives as well as the mode
28
 * of discovery for said instances. In this class a "file" is a full
29
 * path to a verified file while a "path" is relative to its source
30
 * or destination and may indicate either a file or directory of
31
 * unconfirmed existence.
32
 *
33
 * Class failures throw the PublisherException, but some underlying
34
 * methods may percolate different exceptions, like FileException,
35
 * FileNotFoundException or InvalidArgumentException.
36
 *
37
 * Write operations will catch all errors in the file-specific
38
 * $errors property to minimize impact of partial batch operations.
39
 */
40
class Publisher extends FileCollection
41
{
42
    /**
43
     * Array of discovered Publishers.
44
     *
45
     * @var array<string, list<self>|null>
46
     */
47
    private static array $discovered = [];
48

49
    /**
50
     * Directory to use for methods that need temporary storage.
51
     * Created on-the-fly as needed.
52
     */
53
    private ?string $scratch = null;
54

55
    /**
56
     * Exceptions for specific files from the last write operation.
57
     *
58
     * @var array<string, Throwable>
59
     */
60
    private array $errors = [];
61

62
    /**
63
     * List of file published curing the last write operation.
64
     *
65
     * @var list<string>
66
     */
67
    private array $published = [];
68

69
    /**
70
     * List of allowed directories and their allowed files regex.
71
     * Restrictions are intentionally private to prevent overriding.
72
     *
73
     * @var array<string,string>
74
     */
75
    private readonly array $restrictions;
76

77
    private readonly ContentReplacer $replacer;
78

79
    /**
80
     * Base path to use for the source.
81
     *
82
     * @var string
83
     */
84
    protected $source = ROOTPATH;
85

86
    /**
87
     * Base path to use for the destination.
88
     *
89
     * @var string
90
     */
91
    protected $destination = FCPATH;
92

93
    // --------------------------------------------------------------------
94
    // Support Methods
95
    // --------------------------------------------------------------------
96

97
    /**
98
     * Discovers and returns all Publishers in the specified namespace directory.
99
     *
100
     * @return list<self>
101
     */
102
    final public static function discover(string $directory = 'Publishers'): array
103
    {
104
        if (isset(self::$discovered[$directory])) {
105
            return self::$discovered[$directory];
106
        }
107

108
        self::$discovered[$directory] = [];
109

110
        /** @var FileLocatorInterface $locator */
111
        $locator = service('locator');
112

113
        if ([] === $files = $locator->listFiles($directory)) {
114
            return [];
115
        }
116

117
        // Loop over each file checking to see if it is a Publisher
118
        foreach (array_unique($files) as $file) {
119
            $className = $locator->findQualifiedNameFromPath($file);
120

121
            if ($className !== false && class_exists($className) && is_a($className, self::class, true)) {
122
                self::$discovered[$directory][] = new $className();
123
            }
124
        }
125

126
        sort(self::$discovered[$directory]);
127

128
        return self::$discovered[$directory];
129
    }
130

131
    /**
132
     * Removes a directory and all its files and subdirectories.
133
     */
134
    private static function wipeDirectory(string $directory): void
135
    {
136
        if (is_dir($directory)) {
137
            // Try a few times in case of lingering locks
138
            $attempts = 10;
139

140
            while ((bool) $attempts && ! delete_files($directory, true, false, true)) {
141
                // @codeCoverageIgnoreStart
142
                $attempts--;
143
                usleep(100000); // .1s
144
                // @codeCoverageIgnoreEnd
145
            }
146

147
            @rmdir($directory);
148
        }
149
    }
150

151
    // --------------------------------------------------------------------
152
    // Class Core
153
    // --------------------------------------------------------------------
154

155
    /**
156
     * Loads the helper and verifies the source and destination directories.
157
     */
158
    public function __construct(?string $source = null, ?string $destination = null)
159
    {
160
        helper(['filesystem']);
161

162
        $this->source      = self::resolveDirectory($source ?? $this->source);
163
        $this->destination = self::resolveDirectory($destination ?? $this->destination);
164

165
        $this->replacer = new ContentReplacer();
166

167
        // Restrictions are intentionally not injected to prevent overriding
168
        $this->restrictions = config(PublisherConfig::class)->restrictions;
169

170
        // Make sure the destination is allowed
171
        foreach (array_keys($this->restrictions) as $directory) {
172
            if (str_starts_with($this->destination, $directory)) {
173
                return;
174
            }
175
        }
176

177
        throw PublisherException::forDestinationNotAllowed($this->destination);
178
    }
179

180
    /**
181
     * Cleans up any temporary files in the scratch space.
182
     */
183
    public function __destruct()
184
    {
185
        if (isset($this->scratch)) {
186
            self::wipeDirectory($this->scratch);
187

188
            $this->scratch = null;
189
        }
190
    }
191

192
    /**
193
     * Reads files from the sources and copies them out to their destinations.
194
     * This method should be reimplemented by child classes intended for
195
     * discovery.
196
     *
197
     * @throws RuntimeException
198
     */
199
    public function publish(): bool
200
    {
201
        // Safeguard against accidental misuse
202
        if ($this->source === ROOTPATH && $this->destination === FCPATH) {
203
            throw new RuntimeException('Child classes of Publisher should provide their own publish method or a source and destination.');
204
        }
205

206
        return $this->addPath('/')->merge(true);
207
    }
208

209
    // --------------------------------------------------------------------
210
    // Property Accessors
211
    // --------------------------------------------------------------------
212

213
    /**
214
     * Returns the source directory.
215
     */
216
    final public function getSource(): string
217
    {
218
        return $this->source;
219
    }
220

221
    /**
222
     * Returns the destination directory.
223
     */
224
    final public function getDestination(): string
225
    {
226
        return $this->destination;
227
    }
228

229
    /**
230
     * Returns the temporary workspace, creating it if necessary.
231
     */
232
    final public function getScratch(): string
233
    {
234
        if ($this->scratch === null) {
235
            $this->scratch = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . bin2hex(random_bytes(6)) . DIRECTORY_SEPARATOR;
236
            mkdir($this->scratch, 0700);
237
            $this->scratch = realpath($this->scratch) ? realpath($this->scratch) . DIRECTORY_SEPARATOR
238
                : $this->scratch;
239
        }
240

241
        return $this->scratch;
242
    }
243

244
    /**
245
     * Returns errors from the last write operation if any.
246
     *
247
     * @return array<string,Throwable>
248
     */
249
    final public function getErrors(): array
250
    {
251
        return $this->errors;
252
    }
253

254
    /**
255
     * Returns the files published by the last write operation.
256
     *
257
     * @return list<string>
258
     */
259
    final public function getPublished(): array
260
    {
261
        return $this->published;
262
    }
263

264
    // --------------------------------------------------------------------
265
    // Additional Handlers
266
    // --------------------------------------------------------------------
267

268
    /**
269
     * Verifies and adds paths to the list.
270
     *
271
     * @param list<string> $paths
272
     *
273
     * @return $this
274
     */
275
    final public function addPaths(array $paths, bool $recursive = true)
276
    {
277
        foreach ($paths as $path) {
278
            $this->addPath($path, $recursive);
279
        }
280

281
        return $this;
282
    }
283

284
    /**
285
     * Adds a single path to the file list.
286
     *
287
     * @return $this
288
     */
289
    final public function addPath(string $path, bool $recursive = true)
290
    {
291
        $this->add($this->source . $path, $recursive);
292

293
        return $this;
294
    }
295

296
    /**
297
     * Downloads and stages files from an array of URIs.
298
     *
299
     * @param list<string> $uris
300
     *
301
     * @return $this
302
     */
303
    final public function addUris(array $uris)
304
    {
305
        foreach ($uris as $uri) {
306
            $this->addUri($uri);
307
        }
308

309
        return $this;
310
    }
311

312
    /**
313
     * Downloads a file from the URI, and adds it to the file list.
314
     *
315
     * @param string $uri Because HTTP\URI is stringable it will still be accepted
316
     *
317
     * @return $this
318
     */
319
    final public function addUri(string $uri)
320
    {
321
        // Figure out a good filename (using URI strips queries and fragments)
322
        $file = $this->getScratch() . basename((new URI($uri))->getPath());
323

324
        // Get the content and write it to the scratch space
325
        write_file($file, service('curlrequest')->get($uri)->getBody());
326

327
        return $this->addFile($file);
328
    }
329

330
    // --------------------------------------------------------------------
331
    // Write Methods
332
    // --------------------------------------------------------------------
333

334
    /**
335
     * Removes the destination and all its files and folders.
336
     *
337
     * @return $this
338
     */
339
    final public function wipe()
340
    {
341
        self::wipeDirectory($this->destination);
342

343
        return $this;
344
    }
345

346
    /**
347
     * Copies all files into the destination, does not create directory structure.
348
     *
349
     * @param bool $replace Whether to overwrite existing files.
350
     *
351
     * @return bool Whether all files were copied successfully
352
     */
353
    final public function copy(bool $replace = true): bool
354
    {
355
        $this->errors = $this->published = [];
356

357
        foreach ($this->get() as $file) {
358
            $to = $this->destination . basename($file);
359

360
            try {
361
                $this->safeCopyFile($file, $to, $replace);
362
                $this->published[] = $to;
363
            } catch (Throwable $e) {
364
                $this->errors[$file] = $e;
365
            }
366
        }
367

368
        return $this->errors === [];
369
    }
370

371
    /**
372
     * Merges all files into the destination.
373
     * Creates a mirrored directory structure only for files from source.
374
     *
375
     * @param bool $replace Whether to overwrite existing files.
376
     *
377
     * @return bool Whether all files were copied successfully
378
     */
379
    final public function merge(bool $replace = true): bool
380
    {
381
        $this->errors = $this->published = [];
382

383
        // Get the files from source for special handling
384
        $sourced = self::filterFiles($this->get(), $this->source);
385

386
        // Handle everything else with a flat copy
387
        $this->files = array_diff($this->files, $sourced);
388
        $this->copy($replace);
389

390
        // Copy each sourced file to its relative destination
391
        foreach ($sourced as $file) {
392
            // Resolve the destination path
393
            $to = $this->destination . substr($file, strlen($this->source));
394

395
            try {
396
                $this->safeCopyFile($file, $to, $replace);
397
                $this->published[] = $to;
398
            } catch (Throwable $e) {
399
                $this->errors[$file] = $e;
400
            }
401
        }
402

403
        return $this->errors === [];
404
    }
405

406
    /**
407
     * Replace content
408
     *
409
     * @param array $replaces [search => replace]
410
     */
411
    public function replace(string $file, array $replaces): bool
412
    {
413
        $this->verifyAllowed($file, $file);
414

415
        $content = file_get_contents($file);
416

417
        $newContent = $this->replacer->replace($content, $replaces);
418

419
        $return = file_put_contents($file, $newContent);
420

421
        return $return !== false;
422
    }
423

424
    /**
425
     * Add line after the line with the string
426
     *
427
     * @param string $after String to search.
428
     */
429
    public function addLineAfter(string $file, string $line, string $after): bool
430
    {
431
        $this->verifyAllowed($file, $file);
432

433
        $content = file_get_contents($file);
434

435
        $result = $this->replacer->addAfter($content, $line, $after);
436

437
        if ($result !== null) {
438
            $return = file_put_contents($file, $result);
439

440
            return $return !== false;
441
        }
442

443
        return false;
444
    }
445

446
    /**
447
     * Add line before the line with the string
448
     *
449
     * @param string $before String to search.
450
     */
451
    public function addLineBefore(string $file, string $line, string $before): bool
452
    {
453
        $this->verifyAllowed($file, $file);
454

455
        $content = file_get_contents($file);
456

457
        $result = $this->replacer->addBefore($content, $line, $before);
458

459
        if ($result !== null) {
460
            $return = file_put_contents($file, $result);
461

462
            return $return !== false;
463
        }
464

465
        return false;
466
    }
467

468
    /**
469
     * Verify this is an allowed file for its destination.
470
     */
471
    private function verifyAllowed(string $from, string $to): void
472
    {
473
        // Verify this is an allowed file for its destination
474
        foreach ($this->restrictions as $directory => $pattern) {
475
            if (str_starts_with($to, $directory) && self::matchFiles([$to], $pattern) === []) {
476
                throw PublisherException::forFileNotAllowed($from, $directory, $pattern);
477
            }
478
        }
479
    }
480

481
    /**
482
     * Copies a file with directory creation and identical file awareness.
483
     * Intentionally allows errors.
484
     *
485
     * @throws PublisherException For collisions and restriction violations
486
     */
487
    private function safeCopyFile(string $from, string $to, bool $replace): void
488
    {
489
        // Verify this is an allowed file for its destination
490
        $this->verifyAllowed($from, $to);
491

492
        // Check for an existing file
493
        if (file_exists($to)) {
494
            // If not replacing or if files are identical then consider successful
495
            if (! $replace || same_file($from, $to)) {
496
                return;
497
            }
498

499
            // If it is a directory then do not try to remove it
500
            if (is_dir($to)) {
501
                throw PublisherException::forCollision($from, $to);
502
            }
503

504
            // Try to remove anything else
505
            unlink($to);
506
        }
507

508
        // Make sure the directory exists
509
        if (! is_dir($directory = pathinfo($to, PATHINFO_DIRNAME))) {
510
            mkdir($directory, 0775, true);
511
        }
512

513
        // Allow copy() to throw errors
514
        copy($from, $to);
515
    }
516
}
517

Использование cookies

Мы используем файлы cookie в соответствии с Политикой конфиденциальности и Политикой использования cookies.

Нажимая кнопку «Принимаю», Вы даете АО «СберТех» согласие на обработку Ваших персональных данных в целях совершенствования нашего веб-сайта и Сервиса GitVerse, а также повышения удобства их использования.

Запретить использование cookies Вы можете самостоятельно в настройках Вашего браузера.