3
declare(strict_types=1);
6
* This file is part of CodeIgniter 4 framework.
8
* (c) CodeIgniter Foundation <admin@codeigniter.com>
10
* For the full copyright and license information, please view
11
* the LICENSE file that was distributed with this source code.
14
namespace CodeIgniter\Publisher;
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;
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.
33
* Class failures throw the PublisherException, but some underlying
34
* methods may percolate different exceptions, like FileException,
35
* FileNotFoundException or InvalidArgumentException.
37
* Write operations will catch all errors in the file-specific
38
* $errors property to minimize impact of partial batch operations.
40
class Publisher extends FileCollection
43
* Array of discovered Publishers.
45
* @var array<string, list<self>|null>
47
private static array $discovered = [];
50
* Directory to use for methods that need temporary storage.
51
* Created on-the-fly as needed.
53
private ?string $scratch = null;
56
* Exceptions for specific files from the last write operation.
58
* @var array<string, Throwable>
60
private array $errors = [];
63
* List of file published curing the last write operation.
67
private array $published = [];
70
* List of allowed directories and their allowed files regex.
71
* Restrictions are intentionally private to prevent overriding.
73
* @var array<string,string>
75
private readonly array $restrictions;
77
private readonly ContentReplacer $replacer;
80
* Base path to use for the source.
84
protected $source = ROOTPATH;
87
* Base path to use for the destination.
91
protected $destination = FCPATH;
93
// --------------------------------------------------------------------
95
// --------------------------------------------------------------------
98
* Discovers and returns all Publishers in the specified namespace directory.
102
final public static function discover(string $directory = 'Publishers'): array
104
if (isset(self::$discovered[$directory])) {
105
return self::$discovered[$directory];
108
self::$discovered[$directory] = [];
110
/** @var FileLocatorInterface $locator */
111
$locator = service('locator');
113
if ([] === $files = $locator->listFiles($directory)) {
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);
121
if ($className !== false && class_exists($className) && is_a($className, self::class, true)) {
122
self::$discovered[$directory][] = new $className();
126
sort(self::$discovered[$directory]);
128
return self::$discovered[$directory];
132
* Removes a directory and all its files and subdirectories.
134
private static function wipeDirectory(string $directory): void
136
if (is_dir($directory)) {
137
// Try a few times in case of lingering locks
140
while ((bool) $attempts && ! delete_files($directory, true, false, true)) {
141
// @codeCoverageIgnoreStart
143
usleep(100000); // .1s
144
// @codeCoverageIgnoreEnd
151
// --------------------------------------------------------------------
153
// --------------------------------------------------------------------
156
* Loads the helper and verifies the source and destination directories.
158
public function __construct(?string $source = null, ?string $destination = null)
160
helper(['filesystem']);
162
$this->source = self::resolveDirectory($source ?? $this->source);
163
$this->destination = self::resolveDirectory($destination ?? $this->destination);
165
$this->replacer = new ContentReplacer();
167
// Restrictions are intentionally not injected to prevent overriding
168
$this->restrictions = config(PublisherConfig::class)->restrictions;
170
// Make sure the destination is allowed
171
foreach (array_keys($this->restrictions) as $directory) {
172
if (str_starts_with($this->destination, $directory)) {
177
throw PublisherException::forDestinationNotAllowed($this->destination);
181
* Cleans up any temporary files in the scratch space.
183
public function __destruct()
185
if (isset($this->scratch)) {
186
self::wipeDirectory($this->scratch);
188
$this->scratch = null;
193
* Reads files from the sources and copies them out to their destinations.
194
* This method should be reimplemented by child classes intended for
197
* @throws RuntimeException
199
public function publish(): bool
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.');
206
return $this->addPath('/')->merge(true);
209
// --------------------------------------------------------------------
210
// Property Accessors
211
// --------------------------------------------------------------------
214
* Returns the source directory.
216
final public function getSource(): string
218
return $this->source;
222
* Returns the destination directory.
224
final public function getDestination(): string
226
return $this->destination;
230
* Returns the temporary workspace, creating it if necessary.
232
final public function getScratch(): string
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
241
return $this->scratch;
245
* Returns errors from the last write operation if any.
247
* @return array<string,Throwable>
249
final public function getErrors(): array
251
return $this->errors;
255
* Returns the files published by the last write operation.
257
* @return list<string>
259
final public function getPublished(): array
261
return $this->published;
264
// --------------------------------------------------------------------
265
// Additional Handlers
266
// --------------------------------------------------------------------
269
* Verifies and adds paths to the list.
271
* @param list<string> $paths
275
final public function addPaths(array $paths, bool $recursive = true)
277
foreach ($paths as $path) {
278
$this->addPath($path, $recursive);
285
* Adds a single path to the file list.
289
final public function addPath(string $path, bool $recursive = true)
291
$this->add($this->source . $path, $recursive);
297
* Downloads and stages files from an array of URIs.
299
* @param list<string> $uris
303
final public function addUris(array $uris)
305
foreach ($uris as $uri) {
313
* Downloads a file from the URI, and adds it to the file list.
315
* @param string $uri Because HTTP\URI is stringable it will still be accepted
319
final public function addUri(string $uri)
321
// Figure out a good filename (using URI strips queries and fragments)
322
$file = $this->getScratch() . basename((new URI($uri))->getPath());
324
// Get the content and write it to the scratch space
325
write_file($file, service('curlrequest')->get($uri)->getBody());
327
return $this->addFile($file);
330
// --------------------------------------------------------------------
332
// --------------------------------------------------------------------
335
* Removes the destination and all its files and folders.
339
final public function wipe()
341
self::wipeDirectory($this->destination);
347
* Copies all files into the destination, does not create directory structure.
349
* @param bool $replace Whether to overwrite existing files.
351
* @return bool Whether all files were copied successfully
353
final public function copy(bool $replace = true): bool
355
$this->errors = $this->published = [];
357
foreach ($this->get() as $file) {
358
$to = $this->destination . basename($file);
361
$this->safeCopyFile($file, $to, $replace);
362
$this->published[] = $to;
363
} catch (Throwable $e) {
364
$this->errors[$file] = $e;
368
return $this->errors === [];
372
* Merges all files into the destination.
373
* Creates a mirrored directory structure only for files from source.
375
* @param bool $replace Whether to overwrite existing files.
377
* @return bool Whether all files were copied successfully
379
final public function merge(bool $replace = true): bool
381
$this->errors = $this->published = [];
383
// Get the files from source for special handling
384
$sourced = self::filterFiles($this->get(), $this->source);
386
// Handle everything else with a flat copy
387
$this->files = array_diff($this->files, $sourced);
388
$this->copy($replace);
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));
396
$this->safeCopyFile($file, $to, $replace);
397
$this->published[] = $to;
398
} catch (Throwable $e) {
399
$this->errors[$file] = $e;
403
return $this->errors === [];
409
* @param array $replaces [search => replace]
411
public function replace(string $file, array $replaces): bool
413
$this->verifyAllowed($file, $file);
415
$content = file_get_contents($file);
417
$newContent = $this->replacer->replace($content, $replaces);
419
$return = file_put_contents($file, $newContent);
421
return $return !== false;
425
* Add line after the line with the string
427
* @param string $after String to search.
429
public function addLineAfter(string $file, string $line, string $after): bool
431
$this->verifyAllowed($file, $file);
433
$content = file_get_contents($file);
435
$result = $this->replacer->addAfter($content, $line, $after);
437
if ($result !== null) {
438
$return = file_put_contents($file, $result);
440
return $return !== false;
447
* Add line before the line with the string
449
* @param string $before String to search.
451
public function addLineBefore(string $file, string $line, string $before): bool
453
$this->verifyAllowed($file, $file);
455
$content = file_get_contents($file);
457
$result = $this->replacer->addBefore($content, $line, $before);
459
if ($result !== null) {
460
$return = file_put_contents($file, $result);
462
return $return !== false;
469
* Verify this is an allowed file for its destination.
471
private function verifyAllowed(string $from, string $to): void
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);
482
* Copies a file with directory creation and identical file awareness.
483
* Intentionally allows errors.
485
* @throws PublisherException For collisions and restriction violations
487
private function safeCopyFile(string $from, string $to, bool $replace): void
489
// Verify this is an allowed file for its destination
490
$this->verifyAllowed($from, $to);
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)) {
499
// If it is a directory then do not try to remove it
501
throw PublisherException::forCollision($from, $to);
504
// Try to remove anything else
508
// Make sure the directory exists
509
if (! is_dir($directory = pathinfo($to, PATHINFO_DIRNAME))) {
510
mkdir($directory, 0775, true);
513
// Allow copy() to throw errors