Passed
Push — main ( dd4eea...91afa7 )
by Dimitri
03:22
created

Publisher::safeCopyFile()   A

Complexity

Conditions 6
Paths 6

Size

Total Lines 28
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 10
c 1
b 0
f 0
nc 6
nop 3
dl 0
loc 28
rs 9.2222
1
<?php
2
3
/**
4
 * This file is part of Blitz PHP framework.
5
 *
6
 * (c) 2022 Dimitri Sitchet Tomkeu <[email protected]>
7
 *
8
 * For the full copyright and license information, please view
9
 * the LICENSE file that was distributed with this source code.
10
 */
11
12
namespace BlitzPHP\Publisher;
13
14
use BlitzPHP\Container\Services;
15
use BlitzPHP\Exceptions\PublisherException;
16
use BlitzPHP\Filesystem\Files\FileCollection;
0 ignored issues
show
Bug introduced by
The type BlitzPHP\Filesystem\Files\FileCollection was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
17
use BlitzPHP\Http\Uri;
18
use RuntimeException;
19
use Throwable;
20
21
/**
22
 * Les éditeurs lisent les chemins d'accès aux fichiers à partir de diverses sources et copient les fichiers vers différentes destinations.
23
 * Cette classe sert à la fois de base pour les directives de publication individuelles et de mode de découverte pour lesdites instances.
24
 * Dans cette classe, un "fichier" est un chemin complet vers un fichier vérifié tandis qu'un "chemin" est relatif à sa source ou à sa destination et peut indiquer soit un fichier, soit un répertoire dont l'existence n'est pas confirmée.
25
 *
26
 * Les échecs de classe lancent l'exception PublisherException,
27
 * mais certaines méthodes sous-jacentes peuvent percoler différentes exceptions,
28
 * comme FileException, FileNotFoundException ou InvalidArgumentException.
29
 *
30
 * Les opérations d'écriture intercepteront toutes les erreurs dans le fichier spécifique
31
 * Propriété $errors pour minimiser l'impact des opérations par lots partielles.
32
 * 
33
 * @credit <a href="http://codeigniter.com">CodeIgniter 4 - \CodeIgniter\Publisher\Publisher</a>
34
 */
35
class Publisher extends FileCollection
36
{
37
    /**
38
     * Tableau des éditeurs découverts.
39
     *
40
     * @var array<string, self[]|null>
41
     */
42
    private static array $discovered = [];
43
44
    /**
45
     * Répertoire à utiliser pour les méthodes nécessitant un stockage temporaire.
46
     * Créé à la volée selon les besoins.
47
     */
48
    private ?string $scratch = null;
49
50
    /**
51
     * Exceptions pour des fichiers spécifiques de la dernière opération d'écriture.
52
     *
53
     * @var array<string, Throwable>
54
     */
55
    private array $errors = [];
56
57
    /**
58
     * Liste des fichiers publiés traitant la dernière opération d'écriture.
59
     *
60
     * @var string[]
61
     */
62
    private array $published = [];
63
64
    /**
65
     * Liste des répertoires autorisés et leur regex de fichiers autorisés.
66
     * Les restrictions sont intentionnellement privées pour éviter qu'elles ne soient dépassées.
67
     *
68
     * @var array<string,string>
69
     */
70
    private array $restrictions;
71
72
    private ContentReplacer $replacer;
73
74
    /**
75
     * Chemin de base à utiliser pour la source.
76
     */
77
    protected string $source = ROOTPATH;
78
79
    /**
80
     * Chemin de base à utiliser pour la destination.
81
     */
82
    protected string $destination = WEBROOT;
0 ignored issues
show
Bug introduced by
The constant BlitzPHP\Publisher\WEBROOT was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
83
84
    // --------------------------------------------------------------------
85
    // Méthodes d'assistance
86
    // --------------------------------------------------------------------
87
88
    /**
89
     * Découvre et renvoie tous les éditeurs dans le répertoire d'espace de noms spécifié.
90
     *
91
     * @return self[]
92
     */
93
    final public static function discover(string $directory = 'Publishers'): array
94
    {
95
        if (isset(self::$discovered[$directory])) {
96
            return self::$discovered[$directory];
97
        }
98
99
        self::$discovered[$directory] = [];
100
101
        $locator = Services::locator();
102
103
        if ([] === $files = $locator->listFiles($directory)) {
104
            return [];
105
        }
106
107
        // Boucle sur chaque fichier en vérifiant s'il s'agit d'un Publisher
108
        foreach (array_unique($files) as $file) {
109
            $className = $locator->getClassname($file);
110
111
            if ($className !== '' && class_exists($className) && is_a($className, self::class, true)) {
112
                self::$discovered[$directory][] = Services::factory($className);
113
            }
114
        }
115
116
        sort(self::$discovered[$directory]);
117
118
        return self::$discovered[$directory];
119
    }
120
121
    /**
122
     * Supprime un répertoire et tous ses fichiers et sous-répertoires.
123
     */
124
    private static function wipeDirectory(string $directory): void
125
    {
126
        if (is_dir($directory)) {
127
            // Essayez plusieurs fois en cas de mèches persistantes
128
            $attempts = 10;
129
130
            while ((bool) $attempts && ! delete_files($directory, true, false, true)) {
131
                // @codeCoverageIgnoreStart
132
                $attempts--;
133
                usleep(100000); // .1s
134
                // @codeCoverageIgnoreEnd
135
            }
136
137
            @rmdir($directory);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for rmdir(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

137
            /** @scrutinizer ignore-unhandled */ @rmdir($directory);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
138
        }
139
    }
140
141
    /**
142
     * Charge l'assistant et vérifie les répertoires source et destination.
143
     */
144
    public function __construct(?string $source = null, ?string $destination = null)
145
    {
146
        helper('filesystem');
147
148
        $this->source      = self::resolveDirectory($source ?? $this->source);
149
        $this->destination = self::resolveDirectory($destination ?? $this->destination);
150
151
        $this->replacer = new ContentReplacer();
152
153
        // Les restrictions ne sont intentionnellement pas injectées pour empêcher le dépassement
154
        $this->restrictions = config('publisher.restrictions');
155
156
        // Assurez-vous que la destination est autorisée
157
        foreach (array_keys($this->restrictions) as $directory) {
158
            if (strpos($this->destination, $directory) === 0) {
159
                return;
160
            }
161
        }
162
163
        throw PublisherException::destinationNotAllowed($this->destination);
164
    }
165
166
    /**
167
     * Nettoie tous les fichiers temporaires dans l'espace de travail.
168
     */
169
    public function __destruct()
170
    {
171
        if (isset($this->scratch)) {
172
            self::wipeDirectory($this->scratch);
0 ignored issues
show
Bug introduced by
It seems like $this->scratch can also be of type null; however, parameter $directory of BlitzPHP\Publisher\Publisher::wipeDirectory() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

172
            self::wipeDirectory(/** @scrutinizer ignore-type */ $this->scratch);
Loading history...
173
174
            $this->scratch = null;
175
        }
176
    }
177
178
    /**
179
     * Lit les fichiers à partir des sources et les copie vers leurs destinations.
180
     * Cette méthode devrait être réimplémentée par les classes filles destinées à la découverte.
181
     *
182
     * @throws RuntimeException
183
     */
184
    public function publish(): bool
185
    {
186
        // Protection contre une mauvaise utilisation accidentelle
187
        if ($this->source === ROOTPATH && $this->destination === WEBROOT) {
0 ignored issues
show
Bug introduced by
The constant BlitzPHP\Publisher\WEBROOT was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
188
            throw new RuntimeException('Les classes enfants de Publisher doivent fournir leur propre méthode de publication ou une source et une destination.');
189
        }
190
191
        return $this->addPath('/')->merge(true);
192
    }
193
194
    // --------------------------------------------------------------------
195
    // Accesseurs de propriété
196
    // --------------------------------------------------------------------
197
198
    /**
199
     * Renvoie le répertoire source.
200
     */
201
    final public function getSource(): string
202
    {
203
        return $this->source;
204
    }
205
206
    /**
207
     * Renvoie le répertoire de destination.
208
     */
209
    final public function getDestination(): string
210
    {
211
        return $this->destination;
212
    }
213
214
    /**
215
     * Renvoie l'espace de travail temporaire, en le créant si nécessaire.
216
     */
217
    final public function getScratch(): string
218
    {
219
        if ($this->scratch === null) {
220
            $this->scratch = rtrim(sys_get_temp_dir(), DS) . DS . bin2hex(random_bytes(6)) . DS;
221
            mkdir($this->scratch, 0700);
0 ignored issues
show
Bug introduced by
$this->scratch of type null is incompatible with the type string expected by parameter $directory of mkdir(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

221
            mkdir(/** @scrutinizer ignore-type */ $this->scratch, 0700);
Loading history...
222
            $this->scratch = realpath($this->scratch) ? realpath($this->scratch) . DS
0 ignored issues
show
Bug introduced by
$this->scratch of type null is incompatible with the type string expected by parameter $path of realpath(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

222
            $this->scratch = realpath(/** @scrutinizer ignore-type */ $this->scratch) ? realpath($this->scratch) . DS
Loading history...
223
                : $this->scratch;
224
        }
225
226
        return $this->scratch;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->scratch could return the type null which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
227
    }
228
229
    /**
230
     * Renvoie les erreurs de la dernière opération d'écriture, le cas échéant.
231
     *
232
     * @return array<string,Throwable>
233
     */
234
    final public function getErrors(): array
235
    {
236
        return $this->errors;
237
    }
238
239
    /**
240
     * Renvoie les fichiers publiés par la dernière opération d'écriture.
241
     *
242
     * @return string[]
243
     */
244
    final public function getPublished(): array
245
    {
246
        return $this->published;
247
    }
248
249
    // --------------------------------------------------------------------
250
    // Gestionnaires supplémentaires
251
    // --------------------------------------------------------------------
252
253
    /**
254
     * Vérifie et ajoute des chemins à la liste.
255
     *
256
     * @param string[] $paths
257
     */
258
    final public function addPaths(array $paths, bool $recursive = true): self
259
    {
260
        foreach ($paths as $path) {
261
            $this->addPath($path, $recursive);
262
        }
263
264
        return $this;
265
    }
266
267
    /**
268
     * Ajoute un chemin unique à la liste de fichiers.
269
     */
270
    final public function addPath(string $path, bool $recursive = true): self
271
    {
272
        $this->add($this->source . $path, $recursive);
273
274
        return $this;
275
    }
276
277
    /**
278
     * Télécharge et met en scène des fichiers à partir d'un tableau d'URI.
279
     *
280
     * @param string[] $uris
281
     */
282
    final public function addUris(array $uris): self
283
    {
284
        foreach ($uris as $uri) {
285
            $this->addUri($uri);
286
        }
287
288
        return $this;
289
    }
290
291
    /**
292
     * Télécharge un fichier à partir de l'URI et l'ajoute à la liste des fichiers.
293
     *
294
     * @param string $uri Parce que HTTP\URI est stringable, il sera toujours accepté
295
     */
296
    final public function addUri(string $uri): self
297
    {
298
        // Trouvez un bon nom de fichier (en utilisant des requêtes et des fragments de bandes d'URI)
299
        $file = $this->getScratch() . basename((new Uri($uri))->getPath());
300
301
        // Obtenez le contenu et écrivez-le dans l'espace de travail
302
        write_file($file, Services::httpclient()->get($uri)->body());
303
304
        return $this->addFile($file);
305
    }
306
307
    // --------------------------------------------------------------------
308
    // Méthodes d'écriture
309
    // --------------------------------------------------------------------
310
311
    /**
312
     * Supprime la destination et tous ses fichiers et dossiers.
313
     */
314
    final public function wipe(): self
315
    {
316
        self::wipeDirectory($this->destination);
317
318
        return $this;
319
    }
320
321
    /**
322
     * Copie tous les fichiers dans la destination, ne crée pas de structure de répertoire.
323
     *
324
     * @param bool $replace S'il faut écraser les fichiers existants.
325
     *
326
     * @return bool Si tous les fichiers ont été copiés avec succès
327
     */
328
    final public function copy(bool $replace = true): bool
329
    {
330
        $this->errors = $this->published = [];
331
332
        foreach ($this->get() as $file) {
333
            $to = $this->destination . basename($file);
334
335
            try {
336
                $this->safeCopyFile($file, $to, $replace);
337
                $this->published[] = $to;
338
            } catch (Throwable $e) {
339
                $this->errors[$file] = $e;
340
            }
341
        }
342
343
        return $this->errors === [];
344
    }
345
346
    /**
347
     * Fusionne tous les fichiers dans la destination.
348
     * Crée une structure de répertoires en miroir uniquement pour les fichiers de la source.
349
     *
350
     * @param bool $replace Indique s'il faut écraser les fichiers existants.
351
     *
352
     * @return bool Si tous les fichiers ont été copiés avec succès
353
     */
354
    final public function merge(bool $replace = true): bool
355
    {
356
        $this->errors = $this->published = [];
357
358
        // Obtenez les fichiers de la source pour un traitement spécial
359
        $sourced = self::filterFiles($this->get(), $this->source);
360
361
        // Obtenez les fichiers de la source pour un traitement spécial
362
        $this->files = array_diff($this->files, $sourced);
0 ignored issues
show
Bug Best Practice introduced by
The property files does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
363
        $this->copy($replace);
364
365
        // Copiez chaque fichier source vers sa destination relative
366
        foreach ($sourced as $file) {
367
            // Résoudre le chemin de destination
368
            $to = $this->destination . substr($file, strlen($this->source));
369
370
            try {
371
                $this->safeCopyFile($file, $to, $replace);
372
                $this->published[] = $to;
373
            } catch (Throwable $e) {
374
                $this->errors[$file] = $e;
375
            }
376
        }
377
378
        return $this->errors === [];
379
    }
380
381
    /**
382
     * Remplacer le contenu
383
     *
384
     * @param array $replaces [search => replace]
385
     */
386
    public function replace(string $file, array $replaces): bool
387
    {
388
        $this->verifyAllowed($file, $file);
389
390
        $content = file_get_contents($file);
391
392
        $newContent = $this->replacer->replace($content, $replaces);
393
394
        $return = file_put_contents($file, $newContent);
395
396
        return $return !== false;
397
    }
398
399
    /**
400
     * Ajouter une ligne après la ligne avec la chaîne
401
     *
402
     * @param string $after Chaîne à rechercher.
403
     */
404
    public function addLineAfter(string $file, string $line, string $after): bool
405
    {
406
        $this->verifyAllowed($file, $file);
407
408
        $content = file_get_contents($file);
409
410
        $result = $this->replacer->addAfter($content, $line, $after);
411
412
        if ($result !== null) {
413
            $return = file_put_contents($file, $result);
414
415
            return $return !== false;
416
        }
417
418
        return false;
419
    }
420
421
    /**
422
     * Ajouter une ligne avant la ligne avec la chaîne
423
     *
424
     * @param string $before String à rechercher.
425
     */
426
    public function addLineBefore(string $file, string $line, string $before): bool
427
    {
428
        $this->verifyAllowed($file, $file);
429
430
        $content = file_get_contents($file);
431
432
        $result = $this->replacer->addBefore($content, $line, $before);
433
434
        if ($result !== null) {
435
            $return = file_put_contents($file, $result);
436
437
            return $return !== false;
438
        }
439
440
        return false;
441
    }
442
443
    /**
444
     * Vérifiez qu'il s'agit d'un fichier autorisé pour sa destination
445
     */
446
    private function verifyAllowed(string $from, string $to)
447
    {
448
        // Vérifiez qu'il s'agit d'un fichier autorisé pour sa destination
449
        foreach ($this->restrictions as $directory => $pattern) {
450
            if (strpos($to, $directory) === 0 && self::matchFiles([$to], $pattern) === []) {
451
                throw PublisherException::fileNotAllowed($from, $directory, $pattern);
452
            }
453
        }
454
    }
455
456
    /**
457
     * Copie un fichier avec création de répertoire et reconnaissance de fichier identique.
458
     * Permet intentionnellement des erreurs.
459
     *
460
     * @throws PublisherException Pour les collisions et les violations de restriction
461
     */
462
    private function safeCopyFile(string $from, string $to, bool $replace): void
463
    {
464
        // Vérifiez qu'il s'agit d'un fichier autorisé pour sa destination
465
        $this->verifyAllowed($from, $to);
466
467
        // Rechercher un fichier existant
468
        if (file_exists($to)) {
469
            // S'il n'est pas remplacé ou si les fichiers sont identiques, envisagez de réussir
470
            if (! $replace || same_file($from, $to)) {
471
                return;
472
            }
473
474
            // S'il s'agit d'un répertoire, n'essayez pas de le supprimer
475
            if (is_dir($to)) {
476
                throw PublisherException::collision($from, $to);
477
            }
478
479
            // Essayez de supprimer autre chose
480
            unlink($to);
481
        }
482
483
        // Assurez-vous que le répertoire existe
484
        if (! is_dir($directory = pathinfo($to, PATHINFO_DIRNAME))) {
0 ignored issues
show
Bug introduced by
It seems like $directory = pathinfo($t...isher\PATHINFO_DIRNAME) can also be of type array; however, parameter $filename of is_dir() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

484
        if (! is_dir(/** @scrutinizer ignore-type */ $directory = pathinfo($to, PATHINFO_DIRNAME))) {
Loading history...
485
            mkdir($directory, 0775, true);
0 ignored issues
show
Bug introduced by
It seems like $directory can also be of type array; however, parameter $directory of mkdir() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

485
            mkdir(/** @scrutinizer ignore-type */ $directory, 0775, true);
Loading history...
486
        }
487
488
        // Autoriser copy() à générer des erreurs
489
        copy($from, $to);
490
    }
491
}
492