Passed
Pull Request — main (#18)
by Dimitri
03:55
created

Publisher::__construct()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 20
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 3

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 9
nc 3
nop 2
dl 0
loc 20
ccs 7
cts 7
cp 1
crap 3
rs 9.9666
c 1
b 0
f 0
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;
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 readonly array $restrictions;
71
72
    private readonly 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 4
            return self::$discovered[$directory];
97
        }
98
99 4
        self::$discovered[$directory] = [];
100
101 4
        $locator = Services::locator();
102
103
        if ([] === $files = $locator->listFiles($directory)) {
104 2
            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 2
            $className = $locator->findQualifiedNameFromPath($file);
110
111
            if ($className !== false && class_exists($className) && is_a($className, self::class, true)) {
112 2
                self::$discovered[$directory][] = Services::factory($className);
113
            }
114
        }
115
116 2
        sort(self::$discovered[$directory]);
117
118 2
        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 6
            $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 6
            @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 12
        helper('filesystem');
147
148 12
        $this->source      = self::resolveDirectory($source ?? $this->source);
149 12
        $this->destination = self::resolveDirectory($destination ?? $this->destination);
150
151 12
        $this->replacer = new ContentReplacer();
0 ignored issues
show
Bug introduced by
The property replacer is declared read-only in BlitzPHP\Publisher\Publisher.
Loading history...
152
153
        // Les restrictions ne sont intentionnellement pas injectées pour empêcher le dépassement
154 12
        $this->restrictions = config('publisher.restrictions');
0 ignored issues
show
Documentation Bug introduced by
It seems like config('publisher.restrictions') can also be of type BlitzPHP\Config\Config. However, the property $restrictions is declared as type array<string,string>. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
Bug introduced by
The property restrictions is declared read-only in BlitzPHP\Publisher\Publisher.
Loading history...
155
156
        // Assurez-vous que la destination est autorisée
157
        foreach (array_keys($this->restrictions) as $directory) {
158
            if (str_starts_with($this->destination, $directory)) {
159 12
                return;
160
            }
161
        }
162
163 2
        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 6
            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 6
            $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 2
            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 2
        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 2
        return $this->source;
204
    }
205
206
    /**
207
     * Renvoie le répertoire de destination.
208
     */
209
    final public function getDestination(): string
210
    {
211 4
        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 6
            $this->scratch = rtrim(sys_get_temp_dir(), DS) . DS . bin2hex(random_bytes(6)) . DS;
221 6
            mkdir($this->scratch, 0o700);
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, 0o700);
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 6
        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 8
        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 4
        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): static
259
    {
260
        foreach ($paths as $path) {
261 2
            $this->addPath($path, $recursive);
262
        }
263
264 2
        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): static
271
    {
272 6
        $this->add($this->source . $path, $recursive);
273
274 6
        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): static
283
    {
284
        foreach ($uris as $uri) {
285 2
            $this->addUri($uri);
286
        }
287
288 2
        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): static
297
    {
298
        // Trouvez un bon nom de fichier (en utilisant des requêtes et des fragments de bandes d'URI)
299 2
        $file = $this->getScratch() . basename((new Uri($uri))->getPath());
300
301
        // Obtenez le contenu et écrivez-le dans l'espace de travail
302 2
        write_file($file, service('httpclient')->get($uri)->body());
303
304 2
        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(): static
315
    {
316 2
        self::wipeDirectory($this->destination);
317
318 2
        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 4
        $this->errors = $this->published = [];
331
332
        foreach ($this->get() as $file) {
333 4
            $to = $this->destination . basename($file);
334
335
            try {
336 4
                $this->safeCopyFile($file, $to, $replace);
337 2
                $this->published[] = $to;
338
            } catch (Throwable $e) {
339 4
                $this->errors[$file] = $e;
340
            }
341
        }
342
343 4
        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 4
        $this->errors = $this->published = [];
357
358
        // Obtenez les fichiers de la source pour un traitement spécial
359 4
        $sourced = self::filterFiles($this->get(), $this->source);
360
361
        // Obtenez les fichiers de la source pour un traitement spécial
362 4
        $this->files = array_diff($this->files, $sourced);
363 4
        $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 2
            $to = $this->destination . substr($file, strlen($this->source));
369
370
            try {
371 2
                $this->safeCopyFile($file, $to, $replace);
372 2
                $this->published[] = $to;
373
            } catch (Throwable $e) {
374 2
                $this->errors[$file] = $e;
375
            }
376
        }
377
378 4
        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 2
        $this->verifyAllowed($file, $file);
389
390 2
        $content = file_get_contents($file);
391
392 2
        $newContent = $this->replacer->replace($content, $replaces);
393
394 2
        $return = file_put_contents($file, $newContent);
395
396 2
        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 2
        $this->verifyAllowed($file, $file);
407
408 2
        $content = file_get_contents($file);
409
410 2
        $result = $this->replacer->addAfter($content, $line, $after);
411
412
        if ($result !== null) {
413 2
            $return = file_put_contents($file, $result);
414
415 2
            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 2
        $this->verifyAllowed($file, $file);
429
430 2
        $content = file_get_contents($file);
431
432 2
        $result = $this->replacer->addBefore($content, $line, $before);
433
434
        if ($result !== null) {
435 2
            $return = file_put_contents($file, $result);
436
437 2
            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 (str_starts_with($to, $directory) && self::matchFiles([$to], $pattern) === []) {
451 2
                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 4
        $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 2
                return;
472
            }
473
474
            // S'il s'agit d'un répertoire, n'essayez pas de le supprimer
475
            if (is_dir($to)) {
476 2
                throw PublisherException::collision($from, $to);
477
            }
478
479
            // Essayez de supprimer autre chose
480 2
            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 2
            mkdir($directory, 0o775, 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, 0o775, true);
Loading history...
486
        }
487
488
        // Autoriser copy() à générer des erreurs
489 2
        copy($from, $to);
490
    }
491
}
492