From f13dc33cfea15314d2997128de736687868c8925 Mon Sep 17 00:00:00 2001 From: MGatner Date: Sun, 23 May 2021 19:00:08 +0000 Subject: [PATCH 01/12] Remove pasted imports --- tests/system/Helpers/FilesystemHelperTest.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/system/Helpers/FilesystemHelperTest.php b/tests/system/Helpers/FilesystemHelperTest.php index ea9b378646ef..d7367fe07276 100644 --- a/tests/system/Helpers/FilesystemHelperTest.php +++ b/tests/system/Helpers/FilesystemHelperTest.php @@ -6,9 +6,6 @@ use InvalidArgumentException; use org\bovigo\vfs\vfsStream; -use org\bovigo\vfs\vfsStreamDirectory; -use org\bovigo\vfs\visitor\vfsStreamStructureVisitor; - class FilesystemHelperTest extends CIUnitTestCase { protected function setUp(): void From cd662944750bfdb639fa8ce70df91513e555dca8 Mon Sep 17 00:00:00 2001 From: MGatner Date: Sun, 23 May 2021 19:09:49 +0000 Subject: [PATCH 02/12] Add Publisher --- system/Language/en/Publisher.php | 17 + .../Exceptions/PublisherException.php | 32 + system/Publisher/Publisher.php | 713 ++++++++++++++++++ tests/_support/Publishers/TestPublisher.php | 16 + 4 files changed, 778 insertions(+) create mode 100644 system/Language/en/Publisher.php create mode 100644 system/Publisher/Exceptions/PublisherException.php create mode 100644 system/Publisher/Publisher.php create mode 100644 tests/_support/Publishers/TestPublisher.php diff --git a/system/Language/en/Publisher.php b/system/Language/en/Publisher.php new file mode 100644 index 000000000000..ada599074efe --- /dev/null +++ b/system/Language/en/Publisher.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +// Publisher language settings +return [ + 'expectedFile' => 'Publisher::{0} expects a valid file.', + 'expectedDirectory' => 'Publisher::{0} expects a valid directory.', + 'collision' => 'Publisher encountered an unexpected {0} while copying {1} to {2}.', +]; diff --git a/system/Publisher/Exceptions/PublisherException.php b/system/Publisher/Exceptions/PublisherException.php new file mode 100644 index 000000000000..23a4b54d3e57 --- /dev/null +++ b/system/Publisher/Exceptions/PublisherException.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Publisher\Exceptions; + +use CodeIgniter\Exceptions\FrameworkException; + +class PublisherException extends FrameworkException +{ + public static function forExpectedDirectory(string $caller) + { + return new static(lang('Publisher.expectedDirectory', [$caller])); + } + + public static function forExpectedFile(string $caller) + { + return new static(lang('Publisher.expectedFile', [$caller])); + } + + public static function forCollision(string $from, string $to) + { + return new static(lang('Publisher.collision', [filetype($to), $from, $to])); + } +} diff --git a/system/Publisher/Publisher.php b/system/Publisher/Publisher.php new file mode 100644 index 000000000000..8f599d3292b6 --- /dev/null +++ b/system/Publisher/Publisher.php @@ -0,0 +1,713 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Publisher; + +use CodeIgniter\Autoloader\FileLocator; +use CodeIgniter\Files\File; +use CodeIgniter\HTTP\URI; +use CodeIgniter\Publisher\Exceptions\PublisherException; +use Throwable; + +/** + * Publisher Class + * + * Publishers read in file paths from a variety + * of sources and copy the files out to different + * destinations. + * This class acts both as a base for individual + * publication directives as well as the mode of + * discovery for said instances. + * In this class a "file" is a full path to a + * verified file while a "path" is relative to + * to its source or destination and may indicate + * either a file or directory fo unconfirmed + * existence. + * Class failures throw a PublisherException, + * but some underlying methods may percolate + * different exceptions, like FileException, + * FileNotFoundException, InvalidArgumentException. + * Write operations will catch all errors in the + * file-specific $errors property to minimize + * impact of partial batch operations. + */ +class Publisher +{ + /** + * Array of discovered Publishers. + * + * @var array + */ + private static $discovered = []; + + /** + * Directory to use for methods + * that need temporary storage. + * Created on-the-fly as needed. + * + * @var string|null + */ + private $scratch; + + /** + * The current list of files. + * + * @var string[] + */ + private $files = []; + + /** + * Exceptions for specific files + * from the last write operation. + * + * @var array + */ + private $errors = []; + + /** + * Base path to use for the source. + * + * @var string + */ + protected $source = ROOTPATH; + + /** + * Base path to use for the destination. + * + * @var string + */ + protected $destination = FCPATH; + + /** + * Discovers and returns all Publishers + * in the specified namespace directory. + * + * @return self[] + */ + public static function discover($directory = 'Publishers'): array + { + if (isset(self::$discovered[$directory])) + { + return self::$discovered[$directory]; + } + self::$discovered[$directory] = []; + + /** @var FileLocator $locator */ + $locator = service('locator'); + + if ([] === $files = $locator->listFiles($directory)) + { + return []; + } + + // Loop over each file checking to see if it is a Primer + foreach ($files as $file) + { + $className = $locator->findQualifiedNameFromPath($file); + + if (is_string($className) && class_exists($className) && is_a($className, self::class, true)) + { + self::$discovered[$directory][] = new $className(); + } + } + sort(self::$discovered[$directory]); + + return self::$discovered[$directory]; + } + + //-------------------------------------------------------------------- + + /** + * Resolves a full path and verifies + * it is an actual directory. + * + * @param string $directory + * + * @return string + */ + private static function resolveDirectory(string $directory): string + { + if (! is_dir($directory = set_realpath($directory))) + { + $caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + throw PublisherException::forExpectedDirectory($caller['function']); + } + + return $directory; + } + + /** + * Resolves a full path and verifies + * it is an actual file. + * + * @param string $file + * + * @return string + */ + private static function resolveFile(string $file): string + { + if (! is_file($file = set_realpath($file))) + { + $caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]; + throw PublisherException::forExpectedFile($caller['function']); + } + + return $file; + } + + //-------------------------------------------------------------------- + + /** + * Filters an array of files, removing files not + * part of the given directory (recursive). + * + * @param string[] $files + * @param string $directory + * + * @return string[] + */ + private static function filterFiles(array $files, string $directory): array + { + $directory = self::resolveDirectory($directory); + + return array_filter($files, function ($value) use ($directory) { + return strpos($value, $directory) === 0; + }); + } + + /** + * Returns any files whose basename matches + * the given pattern. + * + * @param string[] $files + * @param string $pattern Regex or pseudo-regex string + * + * @return string[] + */ + private static function matchFiles(array $files, string $pattern) + { + // Convert pseudo-regex into their true form + if (@preg_match($pattern, null) === false) // @phpstan-ignore-line + { + $pattern = str_replace( + ['#', '.', '*', '?'], + ['\#', '\.', '.*', '.'], + $pattern + ); + $pattern = "#{$pattern}#"; + } + + return array_filter($files, function ($value) use ($pattern) { + return (bool) preg_match($pattern, basename($value)); + }); + } + + //-------------------------------------------------------------------- + + /** + * Removes a directory and all its files + * and subdirectories. + * + * @param string $directory + * + * @return void + */ + private static function wipeDirectory(string $directory) + { + if (is_dir($directory)) + { + // Try a few times in case of lingering locks + $attempts = 10; + while ((bool) $attempts && ! delete_files($directory, true, false, true)) + { + $attempts--; + usleep(100000); // .1s + } + + @rmdir($directory); + } + } + + /** + * Copies a file with directory creation + * and identical file awareness. + * Intentionally allows errors. + * + * @param string $from + * @param string $to + * @param bool $replace + * + * @return void + * + * @throws PublisherException For unresolvable collisions + */ + private static function safeCopyFile(string $from, string $to, bool $replace): void + { + // Check for an existing file + if (file_exists($to)) + { + // If not replacing or if files are identical then consider successful + if (! $replace || same_file($from, $to)) + { + return; + } + + // If it is a directory then do not try to remove it + if (is_dir($to)) + { + throw PublisherException::forCollision($from, $to); + } + + // Try to remove anything else + unlink($to); + } + + // Allow copy() to throw errors + copy($from, $to); + } + + //-------------------------------------------------------------------- + + /** + * Loads the helper and verifies the + * source and destination directories. + * + * @param string|null $source + * @param string|null $destination + */ + public function __construct(string $source = null, string $destination = null) + { + helper(['filesystem']); + + $this->source = self::resolveDirectory($source ?? $this->source); + $this->destination = self::resolveDirectory($destination ?? $this->destination); + } + + /** + * Cleans up any temporary files + * in the scratch space. + */ + public function __destruct() + { + if (isset($this->scratch)) + { + self::wipeDirectory($this->scratch); + + $this->scratch = null; + } + } + + /** + * Reads in file sources and copies out + * the files to their destinations. + * This method should be reimplemented by + * child classes intended for discovery. + * + * @return void + */ + public function publish() + { + } + + //-------------------------------------------------------------------- + + /** + * Returns the temporary workspace, + * creating it if necessary. + * + * @return string + */ + protected function getScratch(): string + { + if (is_null($this->scratch)) + { + $this->scratch = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . bin2hex(random_bytes(6)) . DIRECTORY_SEPARATOR; + mkdir($this->scratch, 0700); + } + + return $this->scratch; + } + + /** + * Returns any errors from the last + * write operation. + * + * @return array + */ + public function getErrors(): array + { + return $this->errors; + } + + /** + * Optimizes and returns the + * current file list. + * + * @return string[] + */ + public function getFiles(): array + { + $this->files = array_unique($this->files, SORT_STRING); + sort($this->files, SORT_STRING); + + return $this->files; + } + + /** + * Sets the file list directly. + * Files are still subject to verification. + * This works as a "reset" method with []. + * + * @param string[] $files A new file list to use + * + * @return $this + */ + public function setFiles(array $files) + { + $this->files = []; + + return $this->addFiles($files); + } + + //-------------------------------------------------------------------- + + /** + * Verifies and adds files to the list. + * + * @param string[] $files + * + * @return $this + */ + public function addFiles(array $files) + { + foreach ($files as $file) + { + $this->addFile($file); + } + + return $this; + } + + /** + * Verifies and adds a single file + * to the file list. + * + * @param string $file + * + * @return $this + */ + public function addFile(string $file) + { + $this->files[] = self::resolveFile($file); + + return $this; + } + + /** + * Removes files from the list. + * + * @param string[] $files + * + * @return $this + */ + public function removeFiles(array $files) + { + $this->files = array_diff($this->files, $files); + + return $this; + } + + /** + * Removes a single file from the list. + * + * @param string $file + * + * @return $this + */ + public function removeFile(string $file) + { + return $this->removeFiles([$file]); + } + + //-------------------------------------------------------------------- + + /** + * Verifies and adds files from each + * directory to the list. + * + * @param string[] $directories + * @param bool $recursive + * + * @return $this + */ + public function addDirectories(array $directories, bool $recursive = false) + { + foreach ($directories as $directory) + { + $this->addDirectory($directory, $recursive); + } + + return $this; + } + + /** + * Verifies and adds all files + * from a directory. + * + * @param string $directory + * @param bool $recursive + * + * @return $this + */ + public function addDirectory(string $directory, bool $recursive = false) + { + $directory = self::resolveDirectory($directory); + + // Map the directory to depth 2 to so directories become arrays + foreach (directory_map($directory, 2, true) as $key => $path) + { + if (is_string($path)) + { + $this->addFile($directory . $path); + } + elseif ($recursive && is_array($path)) + { + $this->addDirectory($directory . $key, true); + } + } + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Verifies and adds paths to the list. + * + * @param string[] $paths + * @param bool $recursive + * + * @return $this + */ + public function addPaths(array $paths, bool $recursive = true) + { + foreach ($paths as $path) + { + $this->addPath($path, $recursive); + } + + return $this; + } + + /** + * Adds a single path to the file list. + * + * @param string $path + * @param bool $recursive + * + * @return $this + */ + public function addPath(string $path, bool $recursive = true) + { + $full = $this->source . $path; + + // Test for a directory + try + { + $directory = self::resolveDirectory($full); + } + catch (PublisherException $e) + { + return $this->addFile($full); + } + + return $this->addDirectory($full, $recursive); + } + + //-------------------------------------------------------------------- + + /** + * Downloads and stages files from + * an array of URIs. + * + * @param string[] $uris + * + * @return $this + */ + public function addUris(array $uris) + { + foreach ($uris as $uri) + { + $this->addUri($uri); + } + + return $this; + } + + /** + * Downloads a file from the URI + * and adds it to the file list. + * + * @param string $uri Because HTTP\URI is stringable it will still be accepted + * + * @return $this + */ + public function addUri(string $uri) + { + // Figure out a good filename (using URI strips queries and fragments) + $file = $this->getScratch() . basename((new URI($uri))->getPath()); + + // Get the content and write it to the scratch space + $response = service('curlrequest')->get($uri); + write_file($file, $response->getBody()); + + return $this->addFile($file); + } + + //-------------------------------------------------------------------- + + /** + * Removes any files from the list that match + * the supplied pattern (within the optional scope). + * + * @param string $pattern Regex or pseudo-regex string + * @param string|null $scope A directory to limit the scope + * + * @return $this + */ + public function removePattern(string $pattern, string $scope = null) + { + if ($pattern === '') + { + return $this; + } + + // Start with all files or those in scope + $files = is_null($scope) + ? $this->files + : self::filterFiles($this->files, $scope); + + // Remove any files that match the pattern + return $this->removeFiles(self::matchFiles($files, $pattern)); + } + + /** + * Keeps only the files from the list that match + * the supplied pattern (within the optional scope). + * + * @param string $pattern Regex or pseudo-regex string + * @param string|null $scope A directory to limit the scope + * + * @return $this + */ + public function retainPattern(string $pattern, string $scope = null) + { + if ($pattern === '') + { + return $this; + } + + // Start with all files or those in scope + $files = is_null($scope) + ? $this->files + : self::filterFiles($this->files, $scope); + + // Match the pattern within the scoped files + $matched = self::matchFiles($files, $pattern); + + // ... and remove their inverse + return $this->removeFiles(array_diff($files, $matched)); + } + + //-------------------------------------------------------------------- + + /** + * Removes the destination and all its files and folders. + * + * @return $this + */ + public function wipe() + { + self::wipeDirectory($this->destination); + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Copies all files into the destination. + * Does not create directory structure. + * + * @param bool $replace Whether to overwrite existing files. + * + * @return boolean Whether all files were copied successfully + */ + public function copy(bool $replace = true): bool + { + $this->errors = []; + + foreach ($this->getFiles() as $file) + { + $to = $this->destination . basename($file); + + try + { + self::safeCopyFile($file, $to, $replace); + } + catch (Throwable $e) + { + $this->errors[$file] = $e; + } + } + + return $this->errors === []; + } + + /** + * Merges all files into the destination. + * Creates a mirrored directory structure + * only for files from source. + * + * @param bool $replace Whether to overwrite existing files. + * + * @return boolean Whether all files were copied successfully + */ + public function merge(bool $replace = true): bool + { + $this->errors = []; + + // Get the file from source for special handling + $sourced = self::matchFiles($this->getFiles(), $this->source); + + // Handle everything else with a flat copy + $this->files = array_diff($this->files, $sourced); + $this->copy($replace); + + // Copy each sourced file to its relative destination + foreach ($sourced as $file) + { + // Resolve the destination path + $to = $this->destination . substr($file, strlen($this->source)); + + try + { + self::safeCopyFile($file, $to, $replace); + } + catch (Throwable $e) + { + $this->errors[$file] = $e; + } + } + + return $this->errors === []; + } +} diff --git a/tests/_support/Publishers/TestPublisher.php b/tests/_support/Publishers/TestPublisher.php new file mode 100644 index 000000000000..8ef30ca90024 --- /dev/null +++ b/tests/_support/Publishers/TestPublisher.php @@ -0,0 +1,16 @@ +downloadFromUrls($urls)->mergeToDirectory(FCPATH . 'assets'); + } +} From 18ae7eadac7c15896a4d6804b27a5ecc792befd1 Mon Sep 17 00:00:00 2001 From: MGatner Date: Mon, 24 May 2021 01:10:02 +0000 Subject: [PATCH 03/12] Add tests --- system/Publisher/Publisher.php | 18 +- tests/system/Publisher/PublisherInputTest.php | 472 ++++++++++++++++++ .../system/Publisher/PublisherOutputTest.php | 202 ++++++++ .../system/Publisher/PublisherSupportTest.php | 199 ++++++++ 4 files changed, 887 insertions(+), 4 deletions(-) create mode 100644 tests/system/Publisher/PublisherInputTest.php create mode 100644 tests/system/Publisher/PublisherOutputTest.php create mode 100644 tests/system/Publisher/PublisherSupportTest.php diff --git a/system/Publisher/Publisher.php b/system/Publisher/Publisher.php index 8f599d3292b6..d7f797e00312 100644 --- a/system/Publisher/Publisher.php +++ b/system/Publisher/Publisher.php @@ -108,8 +108,8 @@ public static function discover($directory = 'Publishers'): array return []; } - // Loop over each file checking to see if it is a Primer - foreach ($files as $file) + // Loop over each file checking to see if it is a Publisher + foreach (array_unique($files) as $file) { $className = $locator->findQualifiedNameFromPath($file); @@ -228,8 +228,10 @@ private static function wipeDirectory(string $directory) $attempts = 10; while ((bool) $attempts && ! delete_files($directory, true, false, true)) { + // @codeCoverageIgnoreStart $attempts--; usleep(100000); // .1s + // @codeCoverageIgnoreEnd } @rmdir($directory); @@ -270,6 +272,13 @@ private static function safeCopyFile(string $from, string $to, bool $replace): v unlink($to); } + // Make sure the directory exists + $directory = pathinfo($to, PATHINFO_DIRNAME); + if (! is_dir($directory)) + { + mkdir($directory, 0775, true); + } + // Allow copy() to throw errors copy($from, $to); } @@ -311,10 +320,11 @@ public function __destruct() * This method should be reimplemented by * child classes intended for discovery. * - * @return void + * @return bool */ public function publish() { + return $this->addPath('/')->merge(true); } //-------------------------------------------------------------------- @@ -686,7 +696,7 @@ public function merge(bool $replace = true): bool $this->errors = []; // Get the file from source for special handling - $sourced = self::matchFiles($this->getFiles(), $this->source); + $sourced = self::filterFiles($this->getFiles(), $this->source); // Handle everything else with a flat copy $this->files = array_diff($this->files, $sourced); diff --git a/tests/system/Publisher/PublisherInputTest.php b/tests/system/Publisher/PublisherInputTest.php new file mode 100644 index 000000000000..553a92b14c53 --- /dev/null +++ b/tests/system/Publisher/PublisherInputTest.php @@ -0,0 +1,472 @@ +assertSame([], $this->getPrivateProperty($publisher, 'files')); + + $publisher->addFile($this->file); + $this->assertSame([$this->file], $this->getPrivateProperty($publisher, 'files')); + } + + public function testAddFileMissing() + { + $publisher = new Publisher(); + + $this->expectException(PublisherException::class); + $this->expectExceptionMessage(lang('Publisher.expectedFile', ['addFile'])); + + $publisher->addFile('TheHillsAreAlive.bmp'); + } + + public function testAddFileDirectory() + { + $publisher = new Publisher(); + + $this->expectException(PublisherException::class); + $this->expectExceptionMessage(lang('Publisher.expectedFile', ['addFile'])); + + $publisher->addFile($this->directory); + } + + public function testAddFiles() + { + $publisher = new Publisher(); + $files = [ + $this->file, + $this->file, + ]; + + $publisher->addFiles($files); + $this->assertSame($files, $this->getPrivateProperty($publisher, 'files')); + } + + //-------------------------------------------------------------------- + + public function testGetFiles() + { + $publisher = new Publisher(); + $publisher->addFile($this->file); + + $this->assertSame([$this->file], $publisher->getFiles()); + } + + public function testGetFilesSorts() + { + $publisher = new Publisher(); + $files = [ + $this->file, + $this->directory . 'apple.php', + ]; + + $publisher->addFiles($files); + + $this->assertSame(array_reverse($files), $publisher->getFiles()); + } + + public function testGetFilesUniques() + { + $publisher = new Publisher(); + $files = [ + $this->file, + $this->file, + ]; + + $publisher->addFiles($files); + $this->assertSame([$this->file], $publisher->getFiles()); + } + + public function testSetFiles() + { + $publisher = new Publisher(); + + $publisher->setFiles([$this->file]); + $this->assertSame([$this->file], $publisher->getFiles()); + } + + public function testSetFilesInvalid() + { + $publisher = new Publisher(); + + $this->expectException(PublisherException::class); + $this->expectExceptionMessage(lang('Publisher.expectedFile', ['addFile'])); + + $publisher->setFiles(['flerb']); + } + + //-------------------------------------------------------------------- + + public function testRemoveFile() + { + $publisher = new Publisher(); + $files = [ + $this->file, + $this->directory . 'apple.php', + ]; + + $publisher->addFiles($files); + + $publisher->removeFile($this->file); + + $this->assertSame([$this->directory . 'apple.php'], $publisher->getFiles()); + } + + public function testRemoveFiles() + { + $publisher = new Publisher(); + $files = [ + $this->file, + $this->directory . 'apple.php', + ]; + + $publisher->addFiles($files); + + $publisher->removeFiles($files); + + $this->assertSame([], $publisher->getFiles()); + } + + //-------------------------------------------------------------------- + + public function testAddDirectoryInvalid() + { + $publisher = new Publisher(); + + $this->expectException(PublisherException::class); + $this->expectExceptionMessage(lang('Publisher.expectedDirectory', ['addDirectory'])); + + $publisher->addDirectory($this->file); + } + + public function testAddDirectory() + { + $publisher = new Publisher(); + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + ]; + + $publisher->addDirectory($this->directory); + + $this->assertSame($expected, $publisher->getFiles()); + } + + public function testAddDirectoryRecursive() + { + $publisher = new Publisher(); + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $publisher->addDirectory(SUPPORTPATH . 'Files', true); + + $this->assertSame($expected, $publisher->getFiles()); + } + + public function testAddDirectories() + { + $publisher = new Publisher(); + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $publisher->addDirectories([ + $this->directory, + SUPPORTPATH . 'Files/baker', + ]); + + $this->assertSame($expected, $publisher->getFiles()); + } + + public function testAddDirectoriesRecursive() + { + $publisher = new Publisher(); + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + SUPPORTPATH . 'Log/Handlers/TestHandler.php', + ]; + + $publisher->addDirectories([ + SUPPORTPATH . 'Files', + SUPPORTPATH . 'Log', + ], true); + + $this->assertSame($expected, $publisher->getFiles()); + } + + //-------------------------------------------------------------------- + + public function testAddPathFile() + { + $publisher = new Publisher(SUPPORTPATH . 'Files'); + + $publisher->addPath('baker/banana.php'); + + $this->assertSame([$this->file], $publisher->getFiles()); + } + + public function testAddPathFileRecursiveDoesNothing() + { + $publisher = new Publisher(SUPPORTPATH . 'Files'); + + $publisher->addPath('baker/banana.php', true); + + $this->assertSame([$this->file], $publisher->getFiles()); + } + + public function testAddPathDirectory() + { + $publisher = new Publisher(SUPPORTPATH . 'Files'); + + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + ]; + + $publisher->addPath('able'); + + $this->assertSame($expected, $publisher->getFiles()); + } + + public function testAddPathDirectoryRecursive() + { + $publisher = new Publisher(SUPPORTPATH); + + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $publisher->addPath('Files'); + + $this->assertSame($expected, $publisher->getFiles()); + } + + public function testAddPaths() + { + $publisher = new Publisher(SUPPORTPATH . 'Files'); + + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $publisher->addPaths([ + 'able', + 'baker/banana.php', + ]); + + $this->assertSame($expected, $publisher->getFiles()); + } + + public function testAddPathsRecursive() + { + $publisher = new Publisher(SUPPORTPATH); + + $expected = [ + $this->directory . 'apple.php', + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + SUPPORTPATH . 'Files/baker/banana.php', + SUPPORTPATH . 'Log/Handlers/TestHandler.php', + ]; + + $publisher->addPaths([ + 'Files', + 'Log', + ], true); + + $this->assertSame($expected, $publisher->getFiles()); + } + + //-------------------------------------------------------------------- + + public function testAddUri() + { + $publisher = new Publisher(); + $publisher->addUri('https://raw.githubusercontent.com/codeigniter4/CodeIgniter4/develop/composer.json'); + + $scratch = $this->getPrivateProperty($publisher, 'scratch'); + + $this->assertSame([$scratch . 'composer.json'], $publisher->getFiles()); + } + + public function testAddUris() + { + $publisher = new Publisher(); + $publisher->addUris([ + 'https://raw.githubusercontent.com/codeigniter4/CodeIgniter4/develop/LICENSE', + 'https://raw.githubusercontent.com/codeigniter4/CodeIgniter4/develop/composer.json', + ]); + + $scratch = $this->getPrivateProperty($publisher, 'scratch'); + + $this->assertSame([$scratch . 'LICENSE', $scratch . 'composer.json'], $publisher->getFiles()); + } + + //-------------------------------------------------------------------- + + public function testRemovePatternEmpty() + { + $publisher = new Publisher(); + $publisher->addDirectory(SUPPORTPATH . 'Files', true); + + $files = $publisher->getFiles(); + + $publisher->removePattern(''); + + $this->assertSame($files, $publisher->getFiles()); + } + + public function testRemovePatternRegex() + { + $publisher = new Publisher(); + $publisher->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + $this->directory . 'apple.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $publisher->removePattern('#[a-z]+_.*#'); + + $this->assertSame($expected, $publisher->getFiles()); + } + + public function testRemovePatternPseudo() + { + $publisher = new Publisher(); + $publisher->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + $this->directory . 'apple.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $publisher->removePattern('*_*.php'); + + $this->assertSame($expected, $publisher->getFiles()); + } + + public function testRemovePatternScope() + { + $publisher = new Publisher(); + $publisher->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $publisher->removePattern('*.php', $this->directory); + + $this->assertSame($expected, $publisher->getFiles()); + } + + //-------------------------------------------------------------------- + + public function testRetainPatternEmpty() + { + $publisher = new Publisher(); + $publisher->addDirectory(SUPPORTPATH . 'Files', true); + + $files = $publisher->getFiles(); + + $publisher->retainPattern(''); + + $this->assertSame($files, $publisher->getFiles()); + } + + public function testRetainPatternRegex() + { + $publisher = new Publisher(); + $publisher->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + $this->directory . 'fig_3.php', + $this->directory . 'prune_ripe.php', + ]; + + $publisher->retainPattern('#[a-z]+_.*#'); + + $this->assertSame($expected, $publisher->getFiles()); + } + + public function testRetainPatternPseudo() + { + $publisher = new Publisher(); + $publisher->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + $this->directory . 'fig_3.php', + ]; + + $publisher->retainPattern('*_?.php'); + + $this->assertSame($expected, $publisher->getFiles()); + } + + public function testRetainPatternScope() + { + $publisher = new Publisher(); + $publisher->addDirectory(SUPPORTPATH . 'Files', true); + + $expected = [ + $this->directory . 'fig_3.php', + SUPPORTPATH . 'Files/baker/banana.php', + ]; + + $publisher->retainPattern('*_?.php', $this->directory); + + $this->assertSame($expected, $publisher->getFiles()); + } +} diff --git a/tests/system/Publisher/PublisherOutputTest.php b/tests/system/Publisher/PublisherOutputTest.php new file mode 100644 index 000000000000..5183d188d6b9 --- /dev/null +++ b/tests/system/Publisher/PublisherOutputTest.php @@ -0,0 +1,202 @@ +structure = [ + 'able' => [ + 'apple.php' => 'Once upon a midnight dreary', + 'bazam' => 'While I pondered weak and weary', + ], + 'boo' => [ + 'far' => 'Upon a tome of long-forgotten lore', + 'faz' => 'There came a tapping up on the door', + ], + 'AnEmptyFolder' => [], + 'simpleFile' => 'A tap-tap-tapping upon my door', + '.hidden' => 'There is no spoon', + ]; + + $this->root = vfsStream::setup('root', null, $this->structure); + } + + //-------------------------------------------------------------------- + + public function testCopy() + { + $publisher = new Publisher($this->directory, $this->root->url()); + $publisher->addFile($this->file); + + $this->assertFileDoesNotExist($this->root->url() . '/banana.php'); + + $result = $publisher->copy(false); + + $this->assertTrue($result); + $this->assertFileExists($this->root->url() . '/banana.php'); + } + + public function testCopyReplace() + { + $file = $this->directory . 'apple.php'; + $publisher = new Publisher($this->directory, $this->root->url() . '/able'); + $publisher->addFile($file); + + $this->assertFileExists($this->root->url() . '/able/apple.php'); + $this->assertFalse(same_file($file, $this->root->url() . '/able/apple.php')); + + $result = $publisher->copy(true); + + $this->assertTrue($result); + $this->assertTrue(same_file($file, $this->root->url() . '/able/apple.php')); + } + + public function testCopyIgnoresSame() + { + $publisher = new Publisher($this->directory, $this->root->url()); + $publisher->addFile($this->file); + + copy($this->file, $this->root->url() . '/banana.php'); + + $result = $publisher->copy(false); + $this->assertTrue($result); + + $result = $publisher->copy(true); + $this->assertTrue($result); + } + + public function testCopyIgnoresCollision() + { + $publisher = new Publisher($this->directory, $this->root->url()); + + mkdir($this->root->url() . '/banana.php'); + + $result = $publisher->addFile($this->file)->copy(false); + $errors = $publisher->getErrors(); + + $this->assertTrue($result); + $this->assertSame([], $errors); + } + + public function testCopyCollides() + { + $publisher = new Publisher($this->directory, $this->root->url()); + $expected = lang('Publisher.collision', ['dir', $this->file, $this->root->url() . '/banana.php']); + + mkdir($this->root->url() . '/banana.php'); + + $result = $publisher->addFile($this->file)->copy(true); + $errors = $publisher->getErrors(); + + $this->assertFalse($result); + $this->assertCount(1, $errors); + $this->assertSame([$this->file], array_keys($errors)); + $this->assertSame($expected, $errors[$this->file]->getMessage()); + } + + //-------------------------------------------------------------------- + + public function testMerge() + { + $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); + + $this->assertFileDoesNotExist($this->root->url() . '/able/fig_3.php'); + $this->assertDirectoryDoesNotExist($this->root->url() . '/baker'); + + $result = $publisher->addPath('/')->merge(false); + + $this->assertTrue($result); + $this->assertFileExists($this->root->url() . '/able/fig_3.php'); + $this->assertDirectoryExists($this->root->url() . '/baker'); + } + + public function testMergeReplace() + { + $this->assertFalse(same_file($this->directory . 'apple.php', $this->root->url() . '/able/apple.php')); + $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); + + $result = $publisher->addPath('/')->merge(true); + + $this->assertTrue($result); + $this->assertTrue(same_file($this->directory . 'apple.php', $this->root->url() . '/able/apple.php')); + } + + public function testMergeCollides() + { + $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); + $expected = lang('Publisher.collision', ['dir', $this->directory . 'fig_3.php', $this->root->url() . '/able/fig_3.php']); + + mkdir($this->root->url() . '/able/fig_3.php'); + + $result = $publisher->addPath('/')->merge(true); + $errors = $publisher->getErrors(); + + $this->assertFalse($result); + $this->assertCount(1, $errors); + $this->assertSame([$this->directory . 'fig_3.php'], array_keys($errors)); + $this->assertSame($expected, $errors[$this->directory . 'fig_3.php']->getMessage()); + } + + //-------------------------------------------------------------------- + + public function testPublish() + { + $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); + + $result = $publisher->publish(); + + $this->assertTrue($result); + $this->assertFileExists($this->root->url() . '/able/fig_3.php'); + $this->assertDirectoryExists($this->root->url() . '/baker'); + $this->assertTrue(same_file($this->directory . 'apple.php', $this->root->url() . '/able/apple.php')); + } +} diff --git a/tests/system/Publisher/PublisherSupportTest.php b/tests/system/Publisher/PublisherSupportTest.php new file mode 100644 index 000000000000..5592c02fc4ca --- /dev/null +++ b/tests/system/Publisher/PublisherSupportTest.php @@ -0,0 +1,199 @@ +assertCount(1, $result); + $this->assertInstanceOf(TestPublisher::class, $result[0]); + } + + public function testDiscoverNothing() + { + $result = Publisher::discover('Nothing'); + + $this->assertSame([], $result); + } + + public function testDiscoverStores() + { + $publisher = Publisher::discover()[0]; + $publisher->addFile($this->file); + + $result = Publisher::discover(); + $this->assertSame($publisher, $result[0]); + $this->assertSame([$this->file], $result[0]->getFiles()); + } + + //-------------------------------------------------------------------- + + public function testResolveDirectoryDirectory() + { + $method = $this->getPrivateMethodInvoker(Publisher::class, 'resolveDirectory'); + + $this->assertSame($this->directory, $method($this->directory)); + } + + public function testResolveDirectoryFile() + { + $method = $this->getPrivateMethodInvoker(Publisher::class, 'resolveDirectory'); + + $this->expectException(PublisherException::class); + $this->expectExceptionMessage(lang('Publisher.expectedDirectory', ['invokeArgs'])); + + $method($this->file); + } + + public function testResolveDirectorySymlink() + { + // Create a symlink to test + $link = sys_get_temp_dir() . DIRECTORY_SEPARATOR . bin2hex(random_bytes(4)); + symlink($this->directory, $link); + + $method = $this->getPrivateMethodInvoker(Publisher::class, 'resolveDirectory'); + + $this->assertSame($this->directory, $method($link)); + + unlink($link); + } + + //-------------------------------------------------------------------- + + public function testResolveFileFile() + { + $method = $this->getPrivateMethodInvoker(Publisher::class, 'resolveFile'); + + $this->assertSame($this->file, $method($this->file)); + } + + public function testResolveFileSymlink() + { + // Create a symlink to test + $link = sys_get_temp_dir() . DIRECTORY_SEPARATOR . bin2hex(random_bytes(4)); + symlink($this->file, $link); + + $method = $this->getPrivateMethodInvoker(Publisher::class, 'resolveFile'); + + $this->assertSame($this->file, $method($link)); + + unlink($link); + } + + public function testResolveFileDirectory() + { + $method = $this->getPrivateMethodInvoker(Publisher::class, 'resolveFile'); + + $this->expectException(PublisherException::class); + $this->expectExceptionMessage(lang('Publisher.expectedFile', ['invokeArgs'])); + + $method($this->directory); + } + + //-------------------------------------------------------------------- + + public function testGetScratch() + { + $publisher = new Publisher(); + $this->assertNull($this->getPrivateProperty($publisher, 'scratch')); + + $method = $this->getPrivateMethodInvoker($publisher, 'getScratch'); + $scratch = $method(); + + $this->assertIsString($scratch); + $this->assertDirectoryExists($scratch); + $this->assertDirectoryIsWritable($scratch); + $this->assertNotNull($this->getPrivateProperty($publisher, 'scratch')); + + // Directory and contents should be removed on __destruct() + $file = $scratch . 'obvious_statement.txt'; + file_put_contents($file, 'Bananas are a most peculiar fruit'); + + $publisher->__destruct(); + + $this->assertFileDoesNotExist($file); + $this->assertDirectoryDoesNotExist($scratch); + } + + public function testGetErrors() + { + $publisher = new Publisher(); + $this->assertSame([], $publisher->getErrors()); + + $expected = [ + $this->file => PublisherException::forCollision($this->file, $this->file), + ]; + + $this->setPrivateProperty($publisher, 'errors', $expected); + + $this->assertSame($expected, $publisher->getErrors()); + } + + //-------------------------------------------------------------------- + + public function testWipeDirectory() + { + $directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . bin2hex(random_bytes(6)); + mkdir($directory, 0700); + $this->assertDirectoryExists($directory); + + $method = $this->getPrivateMethodInvoker(Publisher::class, 'wipeDirectory'); + $method($directory); + + $this->assertDirectoryDoesNotExist($directory); + } + + public function testWipeIgnoresFiles() + { + $method = $this->getPrivateMethodInvoker(Publisher::class, 'wipeDirectory'); + $method($this->file); + + $this->assertFileExists($this->file); + } + + public function testWipe() + { + $directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . bin2hex(random_bytes(6)); + mkdir($directory, 0700); + $this->assertDirectoryExists($directory); + + $publisher = new Publisher($this->directory, $directory); + $publisher->wipe(); + + $this->assertDirectoryDoesNotExist($directory); + } +} From 116fc82effa883686c3b819c9e44ee8adc767252 Mon Sep 17 00:00:00 2001 From: MGatner Date: Mon, 24 May 2021 15:51:16 -0400 Subject: [PATCH 04/12] Apply suggestions from code review Co-authored-by: Mostafa Khudair <59371810+mostafakhudair@users.noreply.github.com> --- system/Language/en/Publisher.php | 4 +- .../Exceptions/PublisherException.php | 10 +- system/Publisher/Publisher.php | 125 ++++++++---------- 3 files changed, 60 insertions(+), 79 deletions(-) diff --git a/system/Language/en/Publisher.php b/system/Language/en/Publisher.php index ada599074efe..cb5ce81f10a4 100644 --- a/system/Language/en/Publisher.php +++ b/system/Language/en/Publisher.php @@ -11,7 +11,7 @@ // Publisher language settings return [ - 'expectedFile' => 'Publisher::{0} expects a valid file.', - 'expectedDirectory' => 'Publisher::{0} expects a valid directory.', 'collision' => 'Publisher encountered an unexpected {0} while copying {1} to {2}.', + 'expectedDirectory' => 'Publisher::{0} expects a valid directory.', + 'expectedFile' => 'Publisher::{0} expects a valid file.', ]; diff --git a/system/Publisher/Exceptions/PublisherException.php b/system/Publisher/Exceptions/PublisherException.php index 23a4b54d3e57..81f9bb4b7174 100644 --- a/system/Publisher/Exceptions/PublisherException.php +++ b/system/Publisher/Exceptions/PublisherException.php @@ -15,6 +15,11 @@ class PublisherException extends FrameworkException { + public static function forCollision(string $from, string $to) + { + return new static(lang('Publisher.collision', [filetype($to), $from, $to])); + } + public static function forExpectedDirectory(string $caller) { return new static(lang('Publisher.expectedDirectory', [$caller])); @@ -24,9 +29,4 @@ public static function forExpectedFile(string $caller) { return new static(lang('Publisher.expectedFile', [$caller])); } - - public static function forCollision(string $from, string $to) - { - return new static(lang('Publisher.collision', [filetype($to), $from, $to])); - } } diff --git a/system/Publisher/Publisher.php b/system/Publisher/Publisher.php index d7f797e00312..b2e9de8d577b 100644 --- a/system/Publisher/Publisher.php +++ b/system/Publisher/Publisher.php @@ -20,24 +20,18 @@ /** * Publisher Class * - * Publishers read in file paths from a variety - * of sources and copy the files out to different - * destinations. - * This class acts both as a base for individual - * publication directives as well as the mode of - * discovery for said instances. - * In this class a "file" is a full path to a - * verified file while a "path" is relative to - * to its source or destination and may indicate - * either a file or directory fo unconfirmed - * existence. - * Class failures throw a PublisherException, - * but some underlying methods may percolate - * different exceptions, like FileException, - * FileNotFoundException, InvalidArgumentException. - * Write operations will catch all errors in the - * file-specific $errors property to minimize - * impact of partial batch operations. + * Publishers read in file paths from a variety of sources and copy + * the files out to different destinations. This class acts both as + * a base for individual publication directives as well as the mode + * of discovery for said instances. In this class a "file" is a full + * path to a verified file while a "path" is relative to its source + * or destination and may indicate either a file or directory of + * unconfirmed existence. + * class failures throw the PublisherException, but some underlying + * methods may percolate different exceptions, like FileException, + * FileNotFoundException or InvalidArgumentException. + * Write operations will catch all errors in the file-specific + * $errors property to minimize impact of partial batch operations. */ class Publisher { @@ -90,14 +84,17 @@ class Publisher * Discovers and returns all Publishers * in the specified namespace directory. * + * @param string $directory + * * @return self[] */ - public static function discover($directory = 'Publishers'): array + public static function discover(string $directory = 'Publishers'): array { if (isset(self::$discovered[$directory])) { return self::$discovered[$directory]; } + self::$discovered[$directory] = []; /** @var FileLocator $locator */ @@ -145,8 +142,7 @@ private static function resolveDirectory(string $directory): string } /** - * Resolves a full path and verifies - * it is an actual file. + * Resolves a full path and verifies it is an actual file. * * @param string $file * @@ -184,11 +180,10 @@ private static function filterFiles(array $files, string $directory): array } /** - * Returns any files whose basename matches - * the given pattern. + * Returns any files whose `basename` matches the given pattern. * - * @param string[] $files - * @param string $pattern Regex or pseudo-regex string + * @param array $files + * @param string $pattern Regex or pseudo-regex string * * @return string[] */ @@ -239,13 +234,12 @@ private static function wipeDirectory(string $directory) } /** - * Copies a file with directory creation - * and identical file awareness. + * Copies a file with directory creation and identical file awareness. * Intentionally allows errors. * - * @param string $from - * @param string $to - * @param bool $replace + * @param string $from + * @param string $to + * @param boolean $replace * * @return void * @@ -301,8 +295,7 @@ public function __construct(string $source = null, string $destination = null) } /** - * Cleans up any temporary files - * in the scratch space. + * Cleans up any temporary files in the scratch space. */ public function __destruct() { @@ -315,14 +308,13 @@ public function __destruct() } /** - * Reads in file sources and copies out - * the files to their destinations. - * This method should be reimplemented by - * child classes intended for discovery. + * Reads files in the sources and copies them out to their destinations. + * This method should be reimplemented by child classes intended for + * discovery. * - * @return bool + * @return boolean */ - public function publish() + public function publish(): bool { return $this->addPath('/')->merge(true); } @@ -347,8 +339,7 @@ protected function getScratch(): string } /** - * Returns any errors from the last - * write operation. + * Returns errors from the last write operation if any. * * @return array */ @@ -358,8 +349,7 @@ public function getErrors(): array } /** - * Optimizes and returns the - * current file list. + * Optimizes and returns the current file list. * * @return string[] */ @@ -372,11 +362,10 @@ public function getFiles(): array } /** - * Sets the file list directly. - * Files are still subject to verification. + * Sets the file list directly, files are still subject to verification. * This works as a "reset" method with []. * - * @param string[] $files A new file list to use + * @param string[] $files The new file list to use * * @return $this */ @@ -407,8 +396,7 @@ public function addFiles(array $files) } /** - * Verifies and adds a single file - * to the file list. + * Verifies and adds a single file to the file list. * * @param string $file * @@ -469,11 +457,10 @@ public function addDirectories(array $directories, bool $recursive = false) } /** - * Verifies and adds all files - * from a directory. + * Verifies and adds all files from a directory. * - * @param string $directory - * @param bool $recursive + * @param string $directory + * @param boolean $recursive * * @return $this */ @@ -520,8 +507,8 @@ public function addPaths(array $paths, bool $recursive = true) /** * Adds a single path to the file list. * - * @param string $path - * @param bool $recursive + * @param string $path + * @param boolean $recursive * * @return $this */ @@ -563,8 +550,7 @@ public function addUris(array $uris) } /** - * Downloads a file from the URI - * and adds it to the file list. + * Downloads a file from the URI, and adds it to the file list. * * @param string $uri Because HTTP\URI is stringable it will still be accepted * @@ -576,8 +562,7 @@ public function addUri(string $uri) $file = $this->getScratch() . basename((new URI($uri))->getPath()); // Get the content and write it to the scratch space - $response = service('curlrequest')->get($uri); - write_file($file, $response->getBody()); + write_file($file, service('curlrequest')->get($uri)->getBody()); return $this->addFile($file); } @@ -585,11 +570,11 @@ public function addUri(string $uri) //-------------------------------------------------------------------- /** - * Removes any files from the list that match - * the supplied pattern (within the optional scope). + * Removes any files from the list that match the supplied pattern + * (within the optional scope). * - * @param string $pattern Regex or pseudo-regex string - * @param string|null $scope A directory to limit the scope + * @param string $pattern Regex or pseudo-regex string + * @param string|null $scope The directory to limit the scope * * @return $this */ @@ -601,9 +586,7 @@ public function removePattern(string $pattern, string $scope = null) } // Start with all files or those in scope - $files = is_null($scope) - ? $this->files - : self::filterFiles($this->files, $scope); + $files = is_null($scope) ? $this->files : self::filterFiles($this->files, $scope); // Remove any files that match the pattern return $this->removeFiles(self::matchFiles($files, $pattern)); @@ -611,9 +594,9 @@ public function removePattern(string $pattern, string $scope = null) /** * Keeps only the files from the list that match - * the supplied pattern (within the optional scope). + * (within the optional scope). * - * @param string $pattern Regex or pseudo-regex string + * @param string $pattern Regex or pseudo-regex string * @param string|null $scope A directory to limit the scope * * @return $this @@ -654,10 +637,9 @@ public function wipe() //-------------------------------------------------------------------- /** - * Copies all files into the destination. - * Does not create directory structure. + * Copies all files into the destination, does not create directory structure. * - * @param bool $replace Whether to overwrite existing files. + * @param boolean $replace Whether to overwrite existing files. * * @return boolean Whether all files were copied successfully */ @@ -684,10 +666,9 @@ public function copy(bool $replace = true): bool /** * Merges all files into the destination. - * Creates a mirrored directory structure - * only for files from source. + * Creates a mirrored directory structure only for files from source. * - * @param bool $replace Whether to overwrite existing files. + * @param boolean $replace Whether to overwrite existing files. * * @return boolean Whether all files were copied successfully */ From 865c73a8e414db8f182242783e10104380249aff Mon Sep 17 00:00:00 2001 From: MGatner Date: Tue, 25 May 2021 16:13:09 +0000 Subject: [PATCH 05/12] Apply additional suggestions --- system/Publisher/Publisher.php | 48 ++++++++------------- tests/_support/Publishers/TestPublisher.php | 2 +- 2 files changed, 18 insertions(+), 32 deletions(-) diff --git a/system/Publisher/Publisher.php b/system/Publisher/Publisher.php index b2e9de8d577b..640dd779756e 100644 --- a/system/Publisher/Publisher.php +++ b/system/Publisher/Publisher.php @@ -43,8 +43,7 @@ class Publisher private static $discovered = []; /** - * Directory to use for methods - * that need temporary storage. + * Directory to use for methods that need temporary storage. * Created on-the-fly as needed. * * @var string|null @@ -59,8 +58,7 @@ class Publisher private $files = []; /** - * Exceptions for specific files - * from the last write operation. + * Exceptions for specific files from the last write operation. * * @var array */ @@ -81,8 +79,7 @@ class Publisher protected $destination = FCPATH; /** - * Discovers and returns all Publishers - * in the specified namespace directory. + * Discovers and returns all Publishers in the specified namespace directory. * * @param string $directory * @@ -115,6 +112,7 @@ public static function discover(string $directory = 'Publishers'): array self::$discovered[$directory][] = new $className(); } } + sort(self::$discovered[$directory]); return self::$discovered[$directory]; @@ -123,8 +121,7 @@ public static function discover(string $directory = 'Publishers'): array //-------------------------------------------------------------------- /** - * Resolves a full path and verifies - * it is an actual directory. + * Resolves a full path and verifies it is an actual directory. * * @param string $directory * @@ -162,11 +159,10 @@ private static function resolveFile(string $file): string //-------------------------------------------------------------------- /** - * Filters an array of files, removing files not - * part of the given directory (recursive). + * Removes files that are not part of the given directory (recursive). * * @param string[] $files - * @param string $directory + * @param string $directory * * @return string[] */ @@ -207,15 +203,14 @@ private static function matchFiles(array $files, string $pattern) //-------------------------------------------------------------------- - /** - * Removes a directory and all its files - * and subdirectories. + /* + * Removes a directory and all its files and subdirectories. * * @param string $directory * * @return void */ - private static function wipeDirectory(string $directory) + private static function wipeDirectory(string $directory): void { if (is_dir($directory)) { @@ -267,8 +262,7 @@ private static function safeCopyFile(string $from, string $to, bool $replace): v } // Make sure the directory exists - $directory = pathinfo($to, PATHINFO_DIRNAME); - if (! is_dir($directory)) + if (! is_dir($directory = pathinfo($to, PATHINFO_DIRNAME))) { mkdir($directory, 0775, true); } @@ -280,8 +274,7 @@ private static function safeCopyFile(string $from, string $to, bool $replace): v //-------------------------------------------------------------------- /** - * Loads the helper and verifies the - * source and destination directories. + * Loads the helper and verifies the source and destination directories. * * @param string|null $source * @param string|null $destination @@ -322,8 +315,7 @@ public function publish(): bool //-------------------------------------------------------------------- /** - * Returns the temporary workspace, - * creating it if necessary. + * Returns the temporary workspace, creating it if necessary. * * @return string */ @@ -532,8 +524,7 @@ public function addPath(string $path, bool $recursive = true) //-------------------------------------------------------------------- /** - * Downloads and stages files from - * an array of URIs. + * Downloads and stages files from an array of URIs. * * @param string[] $uris * @@ -609,15 +600,10 @@ public function retainPattern(string $pattern, string $scope = null) } // Start with all files or those in scope - $files = is_null($scope) - ? $this->files - : self::filterFiles($this->files, $scope); - - // Match the pattern within the scoped files - $matched = self::matchFiles($files, $pattern); + $files = is_null($scope) ? $this->files : self::filterFiles($this->files, $scope); - // ... and remove their inverse - return $this->removeFiles(array_diff($files, $matched)); + // Matches the pattern within the scoped files and remove their inverse. + return $this->removeFiles(array_diff($files, self::matchFiles($files, $pattern))); } //-------------------------------------------------------------------- diff --git a/tests/_support/Publishers/TestPublisher.php b/tests/_support/Publishers/TestPublisher.php index 8ef30ca90024..fdb3547478d1 100644 --- a/tests/_support/Publishers/TestPublisher.php +++ b/tests/_support/Publishers/TestPublisher.php @@ -9,7 +9,7 @@ class TestPublisher extends Publisher /** * Runs the defined Operations. */ - public function publish() + public function publish(): bool { $this->downloadFromUrls($urls)->mergeToDirectory(FCPATH . 'assets'); } From 3987bff099567df58112226b088b4860b649bac6 Mon Sep 17 00:00:00 2001 From: MGatner Date: Thu, 27 May 2021 20:15:37 +0000 Subject: [PATCH 06/12] Add Guide, tweak class --- system/Publisher/Publisher.php | 95 +++- tests/_support/Publishers/TestPublisher.php | 55 ++- .../system/Publisher/PublisherOutputTest.php | 26 +- .../system/Publisher/PublisherSupportTest.php | 17 +- user_guide_src/source/libraries/publisher.rst | 437 ++++++++++++++++++ 5 files changed, 598 insertions(+), 32 deletions(-) create mode 100644 user_guide_src/source/libraries/publisher.rst diff --git a/system/Publisher/Publisher.php b/system/Publisher/Publisher.php index 640dd779756e..b9e5cca24e90 100644 --- a/system/Publisher/Publisher.php +++ b/system/Publisher/Publisher.php @@ -15,6 +15,7 @@ use CodeIgniter\Files\File; use CodeIgniter\HTTP\URI; use CodeIgniter\Publisher\Exceptions\PublisherException; +use RuntimeException; use Throwable; /** @@ -64,6 +65,13 @@ class Publisher */ private $errors = []; + /** + * List of file published curing the last write operation. + * + * @var string[] + */ + private $published = []; + /** * Base path to use for the source. * @@ -85,7 +93,7 @@ class Publisher * * @return self[] */ - public static function discover(string $directory = 'Publishers'): array + final public static function discover(string $directory = 'Publishers'): array { if (isset(self::$discovered[$directory])) { @@ -301,7 +309,7 @@ public function __destruct() } /** - * Reads files in the sources and copies them out to their destinations. + * Reads files from the sources and copies them out to their destinations. * This method should be reimplemented by child classes intended for * discovery. * @@ -309,17 +317,42 @@ public function __destruct() */ public function publish(): bool { + if ($this->source === ROOTPATH && $this->destination === FCPATH) + { + throw new RuntimeException('Child classes of Publisher should provide their own source and destination or publish method.'); + } + return $this->addPath('/')->merge(true); } //-------------------------------------------------------------------- + /** + * Returns the source directory. + * + * @return string + */ + final public function getSource(): string + { + return $this->source; + } + + /** + * Returns the destination directory. + * + * @return string + */ + final public function getDestination(): string + { + return $this->destination; + } + /** * Returns the temporary workspace, creating it if necessary. * * @return string */ - protected function getScratch(): string + final public function getScratch(): string { if (is_null($this->scratch)) { @@ -335,17 +368,27 @@ protected function getScratch(): string * * @return array */ - public function getErrors(): array + final public function getErrors(): array { return $this->errors; } + /** + * Returns the files published by the last write operation. + * + * @return string[] + */ + final public function getPublished(): array + { + return $this->published; + } + /** * Optimizes and returns the current file list. * * @return string[] */ - public function getFiles(): array + final public function getFiles(): array { $this->files = array_unique($this->files, SORT_STRING); sort($this->files, SORT_STRING); @@ -353,6 +396,8 @@ public function getFiles(): array return $this->files; } + //-------------------------------------------------------------------- + /** * Sets the file list directly, files are still subject to verification. * This works as a "reset" method with []. @@ -361,15 +406,13 @@ public function getFiles(): array * * @return $this */ - public function setFiles(array $files) + final public function setFiles(array $files) { $this->files = []; return $this->addFiles($files); } - //-------------------------------------------------------------------- - /** * Verifies and adds files to the list. * @@ -377,7 +420,7 @@ public function setFiles(array $files) * * @return $this */ - public function addFiles(array $files) + final public function addFiles(array $files) { foreach ($files as $file) { @@ -394,7 +437,7 @@ public function addFiles(array $files) * * @return $this */ - public function addFile(string $file) + final public function addFile(string $file) { $this->files[] = self::resolveFile($file); @@ -408,7 +451,7 @@ public function addFile(string $file) * * @return $this */ - public function removeFiles(array $files) + final public function removeFiles(array $files) { $this->files = array_diff($this->files, $files); @@ -422,7 +465,7 @@ public function removeFiles(array $files) * * @return $this */ - public function removeFile(string $file) + final public function removeFile(string $file) { return $this->removeFiles([$file]); } @@ -438,7 +481,7 @@ public function removeFile(string $file) * * @return $this */ - public function addDirectories(array $directories, bool $recursive = false) + final public function addDirectories(array $directories, bool $recursive = false) { foreach ($directories as $directory) { @@ -456,7 +499,7 @@ public function addDirectories(array $directories, bool $recursive = false) * * @return $this */ - public function addDirectory(string $directory, bool $recursive = false) + final public function addDirectory(string $directory, bool $recursive = false) { $directory = self::resolveDirectory($directory); @@ -486,7 +529,7 @@ public function addDirectory(string $directory, bool $recursive = false) * * @return $this */ - public function addPaths(array $paths, bool $recursive = true) + final public function addPaths(array $paths, bool $recursive = true) { foreach ($paths as $path) { @@ -504,7 +547,7 @@ public function addPaths(array $paths, bool $recursive = true) * * @return $this */ - public function addPath(string $path, bool $recursive = true) + final public function addPath(string $path, bool $recursive = true) { $full = $this->source . $path; @@ -530,7 +573,7 @@ public function addPath(string $path, bool $recursive = true) * * @return $this */ - public function addUris(array $uris) + final public function addUris(array $uris) { foreach ($uris as $uri) { @@ -547,7 +590,7 @@ public function addUris(array $uris) * * @return $this */ - public function addUri(string $uri) + final public function addUri(string $uri) { // Figure out a good filename (using URI strips queries and fragments) $file = $this->getScratch() . basename((new URI($uri))->getPath()); @@ -569,7 +612,7 @@ public function addUri(string $uri) * * @return $this */ - public function removePattern(string $pattern, string $scope = null) + final public function removePattern(string $pattern, string $scope = null) { if ($pattern === '') { @@ -592,7 +635,7 @@ public function removePattern(string $pattern, string $scope = null) * * @return $this */ - public function retainPattern(string $pattern, string $scope = null) + final public function retainPattern(string $pattern, string $scope = null) { if ($pattern === '') { @@ -613,7 +656,7 @@ public function retainPattern(string $pattern, string $scope = null) * * @return $this */ - public function wipe() + final public function wipe() { self::wipeDirectory($this->destination); @@ -629,9 +672,9 @@ public function wipe() * * @return boolean Whether all files were copied successfully */ - public function copy(bool $replace = true): bool + final public function copy(bool $replace = true): bool { - $this->errors = []; + $this->errors = $this->published = []; foreach ($this->getFiles() as $file) { @@ -640,6 +683,7 @@ public function copy(bool $replace = true): bool try { self::safeCopyFile($file, $to, $replace); + $this->published[] = $to; } catch (Throwable $e) { @@ -658,9 +702,9 @@ public function copy(bool $replace = true): bool * * @return boolean Whether all files were copied successfully */ - public function merge(bool $replace = true): bool + final public function merge(bool $replace = true): bool { - $this->errors = []; + $this->errors = $this->published = []; // Get the file from source for special handling $sourced = self::filterFiles($this->getFiles(), $this->source); @@ -678,6 +722,7 @@ public function merge(bool $replace = true): bool try { self::safeCopyFile($file, $to, $replace); + $this->published[] = $to; } catch (Throwable $e) { diff --git a/tests/_support/Publishers/TestPublisher.php b/tests/_support/Publishers/TestPublisher.php index fdb3547478d1..9192c3899b83 100644 --- a/tests/_support/Publishers/TestPublisher.php +++ b/tests/_support/Publishers/TestPublisher.php @@ -4,13 +4,62 @@ use CodeIgniter\Publisher\Publisher; -class TestPublisher extends Publisher +final class TestPublisher extends Publisher { /** - * Runs the defined Operations. + * Fakes an error on the given file. + * + * @return $this + */ + public static function setError(string $file) + { + self::$error = $file; + } + + /** + * A file to cause an error + * + * @var string + */ + private static $error = ''; + + /** + * Base path to use for the source. + * + * @var string + */ + protected $source = SUPPORTPATH . 'Files'; + + /** + * Base path to use for the destination. + * + * @var string + */ + protected $destination = WRITEPATH; + + /** + * Fakes a publish event so no files are actually copied. */ public function publish(): bool { - $this->downloadFromUrls($urls)->mergeToDirectory(FCPATH . 'assets'); + $this->errors = $this->published = []; + + $this->addPath(''); + + // Copy each sourced file to its relative destination + foreach ($this->getFiles() as $file) + { + if ($file === self::$error) + { + $this->errors[$file] = new RuntimeException('Have an error, dear.'); + } + else + { + // Resolve the destination path + $this->published[] = $this->destination . substr($file, strlen($this->source)); + } + } + + return $this->errors === []; } } diff --git a/tests/system/Publisher/PublisherOutputTest.php b/tests/system/Publisher/PublisherOutputTest.php index 5183d188d6b9..0f416440b803 100644 --- a/tests/system/Publisher/PublisherOutputTest.php +++ b/tests/system/Publisher/PublisherOutputTest.php @@ -112,6 +112,7 @@ public function testCopyIgnoresSame() $result = $publisher->copy(true); $this->assertTrue($result); + $this->assertSame([$this->root->url() . '/banana.php'], $publisher->getPublished()); } public function testCopyIgnoresCollision() @@ -121,10 +122,10 @@ public function testCopyIgnoresCollision() mkdir($this->root->url() . '/banana.php'); $result = $publisher->addFile($this->file)->copy(false); - $errors = $publisher->getErrors(); $this->assertTrue($result); - $this->assertSame([], $errors); + $this->assertSame([], $publisher->getErrors()); + $this->assertSame([$this->root->url() . '/banana.php'], $publisher->getPublished()); } public function testCopyCollides() @@ -140,6 +141,7 @@ public function testCopyCollides() $this->assertFalse($result); $this->assertCount(1, $errors); $this->assertSame([$this->file], array_keys($errors)); + $this->assertSame([], $publisher->getPublished()); $this->assertSame($expected, $errors[$this->file]->getMessage()); } @@ -148,6 +150,12 @@ public function testCopyCollides() public function testMerge() { $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); + $expected = [ + $this->root->url() . '/able/apple.php', + $this->root->url() . '/able/fig_3.php', + $this->root->url() . '/able/prune_ripe.php', + $this->root->url() . '/baker/banana.php', + ]; $this->assertFileDoesNotExist($this->root->url() . '/able/fig_3.php'); $this->assertDirectoryDoesNotExist($this->root->url() . '/baker'); @@ -157,23 +165,36 @@ public function testMerge() $this->assertTrue($result); $this->assertFileExists($this->root->url() . '/able/fig_3.php'); $this->assertDirectoryExists($this->root->url() . '/baker'); + $this->assertSame($expected, $publisher->getPublished()); } public function testMergeReplace() { $this->assertFalse(same_file($this->directory . 'apple.php', $this->root->url() . '/able/apple.php')); $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); + $expected = [ + $this->root->url() . '/able/apple.php', + $this->root->url() . '/able/fig_3.php', + $this->root->url() . '/able/prune_ripe.php', + $this->root->url() . '/baker/banana.php', + ]; $result = $publisher->addPath('/')->merge(true); $this->assertTrue($result); $this->assertTrue(same_file($this->directory . 'apple.php', $this->root->url() . '/able/apple.php')); + $this->assertSame($expected, $publisher->getPublished()); } public function testMergeCollides() { $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); $expected = lang('Publisher.collision', ['dir', $this->directory . 'fig_3.php', $this->root->url() . '/able/fig_3.php']); + $published = [ + $this->root->url() . '/able/apple.php', + $this->root->url() . '/able/prune_ripe.php', + $this->root->url() . '/baker/banana.php', + ]; mkdir($this->root->url() . '/able/fig_3.php'); @@ -183,6 +204,7 @@ public function testMergeCollides() $this->assertFalse($result); $this->assertCount(1, $errors); $this->assertSame([$this->directory . 'fig_3.php'], array_keys($errors)); + $this->assertSame($published, $publisher->getPublished()); $this->assertSame($expected, $errors[$this->directory . 'fig_3.php']->getMessage()); } diff --git a/tests/system/Publisher/PublisherSupportTest.php b/tests/system/Publisher/PublisherSupportTest.php index 5592c02fc4ca..f17be3460432 100644 --- a/tests/system/Publisher/PublisherSupportTest.php +++ b/tests/system/Publisher/PublisherSupportTest.php @@ -126,13 +126,26 @@ public function testResolveFileDirectory() //-------------------------------------------------------------------- + public function testGetSource() + { + $publisher = new Publisher(ROOTPATH); + + $this->assertSame(ROOTPATH, $publisher->getSource()); + } + + public function testGetDestination() + { + $publisher = new Publisher(ROOTPATH, SUPPORTPATH); + + $this->assertSame(SUPPORTPATH, $publisher->getDestination()); + } + public function testGetScratch() { $publisher = new Publisher(); $this->assertNull($this->getPrivateProperty($publisher, 'scratch')); - $method = $this->getPrivateMethodInvoker($publisher, 'getScratch'); - $scratch = $method(); + $scratch = $publisher->getScratch(); $this->assertIsString($scratch); $this->assertDirectoryExists($scratch); diff --git a/user_guide_src/source/libraries/publisher.rst b/user_guide_src/source/libraries/publisher.rst new file mode 100644 index 000000000000..ef97e0b52f95 --- /dev/null +++ b/user_guide_src/source/libraries/publisher.rst @@ -0,0 +1,437 @@ +######### +Publisher +######### + +The Publisher library provides a means to copy files within a project using robust detection and error checking. + +.. contents:: + :local: + :depth: 2 + +******************* +Loading the Library +******************* + +Because Publisher instances are specific to their source and destination this library is not available +through ``Services`` but should be instantiated or extended directly. E.g. + + $publisher = new \CodeIgniter\Publisher\Publisher(); + +***************** +Concept and Usage +***************** + +``Publisher`` solves a handful of common problems when working within a backend framework: + +* How do I maintain project assets with version dependencies? +* How do I manage uploads and other "dynamic" files that need to be web accessible? +* How can I update my project when the framework or modules change? +* How can components inject new content into existing projects? + +At its most basic, publishing amounts to copying a file or files into a project. ``Publisher`` uses fluent-style +command chaining to read, filter, and process input files, then copies or merges them into the target destination. +You may use ``Publisher`` on demand in your Controllers or other components, or you may stage publications by extending +the class and leveraging its discovery with ``spark publish``. + +On Demand +========= + +Access ``Publisher`` directly by instantiating a new instance of the class:: + + $publisher = new \CodeIgniter\Publisher\Publisher(); + +By default the source and destination will be set to ``ROOTPATH`` and ``FCPATH`` respectively, giving ``Publisher`` +easy access to take any file from your project and make it web-accessible. Alternatively you may pass a new source +or source and destination into the constructor:: + + $vendorPublisher = new Publisher(ROOTPATH . 'vendor'); + $filterPublisher = new Publisher('/path/to/module/Filters', APPPATH . 'Filters'); + +Once the source and destination are set you may start adding relative input files:: + + $frameworkPublisher = new Publisher(ROOTPATH . 'vendor/codeigniter4/codeigniter4'); + + // All "path" commands are relative to $source + $frameworkPublisher->addPath('app/Config/Cookie.php'); + + // You may also add from outside the source, but the files will not be merged into subdirectories + $frameworkPublisher->addFiles([ + '/opt/mail/susan', + '/opt/mail/ubuntu', + ]); + $frameworkPublisher->addDirectory(SUPPORTPATH . 'Images'); + +Once all the files are staged use one of the output commands (**copy()** or **merge()**) to process the staged files +to their destination(s):: + + // Place all files into $destination + $frameworkPublisher->copy(); + + // Place all files into $destination, overwriting existing files + $frameworkPublisher->copy(true); + + // Place files into their relative $destination directories, overwriting and saving the boolean result + $result = $frameworkPublisher->merge(true); + +See the Library Reference for a full description of available methods. + +Automation and Discovery +======================== + +You may have regular publication tasks embedded as part of your application deployment or upkeep. ``Publisher`` leverages +the powerful ``Autoloader`` to locate any child classes primed for publication:: + + use CodeIgniter\CLI\CLI; + use CodeIgniter\Publisher\Publisher; + + foreach (Publisher::discover() as $publisher) + { + $result = $publisher->publish(); + + if ($result === false) + { + CLI::write(get_class($publisher) . ' failed to publish!', 'red'); + } + } + +By default ``discover()`` will search for the "Publishers" directory across all namespaces, but you may specify a +different directory and it will return any child classes found:: + + $memePublishers = Publisher::discover('CatGIFs'); + +Most of the time you will not need to handle your own discovery, just use the provided "publish" command:: + + > php spark publish + +By default on your class extension ``publish()`` will add all files from your ``$source`` and merge them +out to your destination, overwriting on collision. + +******** +Examples +******** + +Here are a handful of example use cases and their implementations to help you get started publishing. + +File Sync Example +================= + +You want to display a "photo of the day" image on your homepage. You have a feed for daily photos but you +need to get the actual file into a browsable location in your project at **public/images/daily_photo.jpg**. +You can set up :doc:`Custom Command ` to run daily that will handle this for you:: + + namespace App\Commands; + + use CodeIgniter\CLI\BaseCommand; + use CodeIgniter\Publisher\Publisher; + use Throwable; + + class DailyPhoto extends BaseCommand + { + protected $group = 'Publication'; + protected $name = 'publish:daily'; + protected $description = 'Publishes the latest daily photo to the homepage.'; + + public function run(array $params) + { + $publisher = new Publisher('/path/to/photos/', FCPATH . 'assets/images'); + + try + { + $publisher->addPath('daily_photo.jpg')->copy($replace = true); + } + catch (Throwable $e) + { + $this->showError($e); + } + } + } + +Now running ``spark publish:daily`` will keep your homepage's image up-to-date. What if the photo is +coming from an external API? You can use ``addUri()`` in place of ``addPath()`` to download the remote +resource and publish it out instead:: + + $publisher->addUri('https://example.com/feeds/daily_photo.jpg')->copy($replace = true); + +Asset Dependencies Example +========================== + +You want to integrate the frontend library "Bootstrap" into your project, but the frequent updates makes it a hassle +to keep up with. You can create a publication definition in your project to sync frontend assets by adding extending +``Publisher`` in your project. So **app/Publishers/BootstrapPublisher.php** might look like this:: + + namespace App\Publishers; + + use CodeIgniter\Publisher\Publisher; + + class BootstrapPublisher extends Publisher + { + /** + * Tell Publisher where to get the files. + * Since we will use Composer to download + * them we point to the "vendor" directory. + * + * @var string + */ + protected $source = 'vendor/twbs/bootstrap/'; + + /** + * FCPATH is always the default destination, + * but we may want them to go in a sub-folder + * to keep things organized. + * + * @var string + */ + protected $destination = FCPATH . 'bootstrap'; + + /** + * Use the "publish" method to indicate that this + * class is ready to be discovered and automated. + * + * @return boolean + */ + public function publish(): bool + { + return $this + // Add all the files relative to $source + ->addPath('dist') + + // Indicate we only want the minimized versions + ->retainPattern('*.min.*) + + // Merge-and-replace to retain the original directory structure + ->merge(true); + } + +Now add the dependency via Composer and call ``spark publish`` to run the publication:: + + > composer require twbs/bootstrap + > php spark publish + +... and you'll end up with something like this: + + public/.htaccess + public/favicon.ico + public/index.php + public/robots.txt + public/ + bootstrap/ + css/ + bootstrap.min.css + bootstrap-utilities.min.css.map + bootstrap-grid.min.css + bootstrap.rtl.min.css + bootstrap.min.css.map + bootstrap-reboot.min.css + bootstrap-utilities.min.css + bootstrap-reboot.rtl.min.css + bootstrap-grid.min.css.map + js/ + bootstrap.esm.min.js + bootstrap.bundle.min.js.map + bootstrap.bundle.min.js + bootstrap.min.js + bootstrap.esm.min.js.map + bootstrap.min.js.map + +Module Deployment Example +========================= + +You want to allow developers using your popular authentication module the ability to expand on the default behavior +of your Migration, Controller, and Model. You can create your own module "publish" command to inject these components +into an application for use:: + + namespace Math\Auth\Commands; + + use CodeIgniter\CLI\BaseCommand; + use CodeIgniter\Publisher\Publisher; + use Throwable; + + class Publish extends BaseCommand + { + protected $group = 'Auth'; + protected $name = 'auth:publish'; + protected $description = 'Publish Auth components into the current application.'; + + public function run(array $params) + { + // Use the Autoloader to figure out the module path + $source = service('autoloader')->getNamespace('Math\\Auth'); + + $publisher = new Publisher($source, APPATH); + + try + { + // Add only the desired components + $publisher->addPaths([ + 'Controllers', + 'Database/Migrations', + 'Models', + ])->merge(false); // Be careful not to overwrite anything + } + catch (Throwable $e) + { + $this->showError($e); + return; + } + + // If publication succeeded then update namespaces + foreach ($publisher->getFiles as $original) + { + // Get the location of the new file + $file = str_replace($source, APPPATH, $original); + + // Replace the namespace + $contents = file_get_contents($file); + $contents = str_replace('namespace Math\\Auth', 'namespace ' . APP_NAMESPACE, ); + file_put_contents($file, $contents); + } + } + } + +Now when your module users run ``php spark auth:publish`` they will have the following added to their project:: + + app/Controllers/AuthController.php + app/Database/Migrations/2017-11-20-223112_create_auth_tables.php.php + app/Models/LoginModel.php + app/Models/UserModel.php + +***************** +Library Reference +***************** + +Support Methods +=============== + +**[static] discover(string $directory = 'Publishers'): Publisher[]** + +Discovers and returns all Publishers in the specified namespace directory. For example, if both +**app/Publishers/FrameworkPublisher.php** and **myModule/src/Publishers/AssetPublisher.php** exist and are +extensions of ``Publisher`` then ``Publisher::discover()`` would return an instance of each. + +**publish(): bool** + +Processes the full input-process-output chain. By default this is the equivalent of calling ``addPath($source)`` +and ``merge(true)`` but child classes will typically provide their own implementation. ``publish()`` is called +on all discovered Publishers when running ``spark publish``. +Returns success or failure. + +**getScratch(): string** + +Returns the temporary workspace, creating it if necessary. Some operations use intermediate storage to stage +files and changes, and this provides the path to a transient, writable directory that you may use as well. + +**getErrors(): array** + +Returns any errors from the last write operation. The array keys are the files that caused the error, and the +values are the Throwable that was caught. Use ``getMessage()`` on the Throwable to get the error message. + +**getFiles(): string[]** + +Returns an array of all the loaded input files. + +Inputting Files +=============== + +**setFiles(array $files)** + +Sets the list of input files to the provided string array of file paths. + +**addFile(string $file)** +**addFiles(array $files)** + +Adds the file or files to the current list of input files. Files are absolute paths to actual files. + +**removeFile(string $file)** +**removeFiles(array $files)** + +Removes the file or files from the current list of input files. + +**addDirectory(string $directory, bool $recursive = false)** +**addDirectories(array $directories, bool $recursive = false)** + +Adds all files from the directory or directories, optionally recursing into sub-directories. Directories are +absolute paths to actual directories. + +**addPath(string $path, bool $recursive = true)** +**addPaths(array $path, bool $recursive = true)** + +Adds all files indicated by the relative paths. Paths are references to actual files or directories relative +to ``$source``. If the relative path resolves to a directory then ``$recursive`` will include sub-directories. + +**addUri(string $uri)** +**addUris(array $uris)** + +Downloads the contents of a URI using ``CURLRequest`` into the scratch workspace then adds the resulting +file to the list. + +.. note:: The CURL request made is a simple ``GET`` and uses the response body for the file contents. Some + remote files may need a custom request to be handled properly. + +Filtering Files +=============== + +**removePattern(string $pattern, string $scope = null)** +**retainPattern(string $pattern, string $scope = null)** + +Filters the current file list through the pattern (and optional scope), removing or retaining matched +files. ``$pattern`` may be a complete regex (like ``'#[A-Za-z]+\.php#'``) or a pseudo-regex similar +to ``glob()`` (like ``*.css``). +If a ``$scope`` is provided then only files in or under that directory will be considered (i.e. files +outside of ``$scope`` are always retained). When no scope is provided then all files are subject. + +Examples:: + + $publisher = new Publisher(APPPATH . 'Config'); + $publisher->addPath('/', true); // Adds all Config files and directories + + $publisher->removePattern('*tion.php'); // Would remove Encryption.php, Validation.php, and boot/production.php + $publisher->removePattern('*tion.php', APPPATH . 'Config/boot'); // Would only remove boot/production.php + + $publisher->retainPattern('#A.+php$#'); // Would keep only Autoload.php + $publisher->retainPattern('#d.+php$#', APPPATH . 'Config/boot'); // Would keep everything but boot/production.php and boot/testing.php + +Outputting Files +================ + +**wipe()** + +Removes all files, directories, and sub-directories from ``$destination``. + +.. important:: Use wisely. + +**copy(bool $replace = true): bool** + +Copies all files into the ``$destination``. This does not recreate the directory structure, so every file +from the current list will end up in the same destination directory. Using ``$replace`` will cause files +to overwrite when there is already an existing file. Returns success or failure, use ``getPublished()`` +and ``getErrors()`` to troubleshoot failures. +Be mindful of duplicate basename collisions, for example:: + + $publisher = new Publisher('/home/source', '/home/destination'); + $publisher->addPaths([ + 'pencil/lead.png', + 'metal/lead.png', + ]); + + // This is bad! Only one file will remain at /home/destination/lead.png + $publisher->copy(true); + +**merge(bool $replace = true): bool** + +Copies all files into the ``$destination`` in appropriate relative sub-directories. Any files that +match ``$source`` will be placed into their equivalent directories in ``$destination``, effectively +creating a "mirror" or "rsync" operation. Using ``$replace`` will cause files +to overwrite when there is already an existing file; since directories are merged this will not +affect other files in the destination. Returns success or failure, use ``getPublished()`` and +``getErrors()`` to troubleshoot failures. + +Example:: + + $publisher = new Publisher('/home/source', '/home/destination'); + $publisher->addPaths([ + 'pencil/lead.png', + 'metal/lead.png', + ]); + + // Results in "/home/destination/pencil/lead.png" and "/home/destination/metal/lead.png" + $publisher->merge(); From ad412189411ce195b972be82422570667a0ed066 Mon Sep 17 00:00:00 2001 From: MGatner Date: Sat, 29 May 2021 15:18:50 +0000 Subject: [PATCH 07/12] Add Publish command --- system/Commands/Utilities/Publish.php | 114 ++++++++++++++++++ system/Language/en/Publisher.php | 5 + system/Publisher/Publisher.php | 1 + tests/_support/Publishers/TestPublisher.php | 29 ++--- tests/system/Commands/PublishCommandTest.php | 52 ++++++++ user_guide_src/source/libraries/publisher.rst | 7 +- 6 files changed, 181 insertions(+), 27 deletions(-) create mode 100644 system/Commands/Utilities/Publish.php create mode 100644 tests/system/Commands/PublishCommandTest.php diff --git a/system/Commands/Utilities/Publish.php b/system/Commands/Utilities/Publish.php new file mode 100644 index 000000000000..b9665286f348 --- /dev/null +++ b/system/Commands/Utilities/Publish.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Utilities; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use CodeIgniter\Publisher\Publisher; + +/** + * Discovers all Publisher classes from the "Publishers/" directory + * across namespaces. Executes `publish()` from each instance, parsing + * each result. + */ +class Publish extends BaseCommand +{ + /** + * The group the command is lumped under + * when listing commands. + * + * @var string + */ + protected $group = 'CodeIgniter'; + + /** + * The Command's name + * + * @var string + */ + protected $name = 'publish'; + + /** + * The Command's short description + * + * @var string + */ + protected $description = 'Discovers and executes all predefined Publisher classes.'; + + /** + * The Command's usage + * + * @var string + */ + protected $usage = 'publish [directory]'; + + /** + * The Command's arguments + * + * @var array + */ + protected $arguments = [ + 'directory' => '[Optional] The directory to scan within each namespace. Default: "Publishers".', + ]; + + /** + * the Command's Options + * + * @var array + */ + protected $options = []; + + //-------------------------------------------------------------------- + + /** + * Displays the help for the spark cli script itself. + * + * @param array $params + */ + public function run(array $params) + { + $directory = array_shift($params) ?? 'Publishers'; + + if ([] === $publishers = Publisher::discover($directory)) + { + CLI::write(lang('Publisher.publishMissing', [$directory])); + return; + } + + foreach ($publishers as $publisher) + { + if ($publisher->publish()) + { + CLI::write(lang('Publisher.publishSuccess', [ + get_class($publisher), + count($publisher->getPublished()), + $publisher->getDestination(), + ]), 'green'); + } + else + { + CLI::error(lang('Publisher.publishFailure', [ + get_class($publisher), + $publisher->getDestination(), + ]), 'light_gray', 'red'); + + foreach ($publisher->getErrors() as $file => $exception) + { + CLI::write($file); + CLI::newLine(); + + $this->showError($exception); + } + } + } + } +} diff --git a/system/Language/en/Publisher.php b/system/Language/en/Publisher.php index cb5ce81f10a4..934342b4f98c 100644 --- a/system/Language/en/Publisher.php +++ b/system/Language/en/Publisher.php @@ -14,4 +14,9 @@ 'collision' => 'Publisher encountered an unexpected {0} while copying {1} to {2}.', 'expectedDirectory' => 'Publisher::{0} expects a valid directory.', 'expectedFile' => 'Publisher::{0} expects a valid file.', + + // Publish Command + 'publishMissing' => 'No Publisher classes detected in {0} across all namespaces.', + 'publishSuccess' => '{0} published {1} file(s) to {2}.', + 'publishFailure' => '{0} failed to publish to {1}!', ]; diff --git a/system/Publisher/Publisher.php b/system/Publisher/Publisher.php index b9e5cca24e90..7018df15c7d1 100644 --- a/system/Publisher/Publisher.php +++ b/system/Publisher/Publisher.php @@ -317,6 +317,7 @@ public function __destruct() */ public function publish(): bool { + // Safeguard against accidental misuse if ($this->source === ROOTPATH && $this->destination === FCPATH) { throw new RuntimeException('Child classes of Publisher should provide their own source and destination or publish method.'); diff --git a/tests/_support/Publishers/TestPublisher.php b/tests/_support/Publishers/TestPublisher.php index 9192c3899b83..cf1eefa7ede6 100644 --- a/tests/_support/Publishers/TestPublisher.php +++ b/tests/_support/Publishers/TestPublisher.php @@ -3,6 +3,7 @@ namespace Tests\Support\Publishers; use CodeIgniter\Publisher\Publisher; +use RuntimeException; final class TestPublisher extends Publisher { @@ -11,17 +12,17 @@ final class TestPublisher extends Publisher * * @return $this */ - public static function setError(string $file) + public static function setResult(bool $result) { - self::$error = $file; + self::$result = $result; } /** - * A file to cause an error + * Return value for publish() * - * @var string + * @var boolean */ - private static $error = ''; + private static $result = true; /** * Base path to use for the source. @@ -42,24 +43,8 @@ public static function setError(string $file) */ public function publish(): bool { - $this->errors = $this->published = []; - $this->addPath(''); - // Copy each sourced file to its relative destination - foreach ($this->getFiles() as $file) - { - if ($file === self::$error) - { - $this->errors[$file] = new RuntimeException('Have an error, dear.'); - } - else - { - // Resolve the destination path - $this->published[] = $this->destination . substr($file, strlen($this->source)); - } - } - - return $this->errors === []; + return self::$result; } } diff --git a/tests/system/Commands/PublishCommandTest.php b/tests/system/Commands/PublishCommandTest.php new file mode 100644 index 000000000000..44f2de25f85a --- /dev/null +++ b/tests/system/Commands/PublishCommandTest.php @@ -0,0 +1,52 @@ +streamFilter = stream_filter_append(STDOUT, 'CITestStreamFilter'); + $this->streamFilter = stream_filter_append(STDERR, 'CITestStreamFilter'); + } + + protected function tearDown(): void + { + parent::tearDown(); + + stream_filter_remove($this->streamFilter); + TestPublisher::setResult(true); + } + + public function testDefault() + { + command('publish'); + + $this->assertStringContainsString(lang('Publisher.publishSuccess', [ + TestPublisher::class, + 0, + WRITEPATH, + ]), CITestStreamFilter::$buffer); + } + + public function testFailure() + { + TestPublisher::setResult(false); + + command('publish'); + + $this->assertStringContainsString(lang('Publisher.publishFailure', [ + TestPublisher::class, + WRITEPATH, + ]), CITestStreamFilter::$buffer); + } +} diff --git a/user_guide_src/source/libraries/publisher.rst b/user_guide_src/source/libraries/publisher.rst index ef97e0b52f95..23f7c2d9e97e 100644 --- a/user_guide_src/source/libraries/publisher.rst +++ b/user_guide_src/source/libraries/publisher.rst @@ -246,7 +246,7 @@ into an application for use:: use CodeIgniter\Publisher\Publisher; use Throwable; - class Publish extends BaseCommand + class AuthPublish extends BaseCommand { protected $group = 'Auth'; protected $name = 'auth:publish'; @@ -275,11 +275,8 @@ into an application for use:: } // If publication succeeded then update namespaces - foreach ($publisher->getFiles as $original) + foreach ($publisher->getPublished() as $file) { - // Get the location of the new file - $file = str_replace($source, APPPATH, $original); - // Replace the namespace $contents = file_get_contents($file); $contents = str_replace('namespace Math\\Auth', 'namespace ' . APP_NAMESPACE, ); From c974add8ac05486a890bfffc7be5ebbb4f293a66 Mon Sep 17 00:00:00 2001 From: MGatner Date: Sat, 29 May 2021 15:43:14 +0000 Subject: [PATCH 08/12] Fix test bleeding --- tests/system/Publisher/PublisherSupportTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/system/Publisher/PublisherSupportTest.php b/tests/system/Publisher/PublisherSupportTest.php index f17be3460432..2aff0e34d1f6 100644 --- a/tests/system/Publisher/PublisherSupportTest.php +++ b/tests/system/Publisher/PublisherSupportTest.php @@ -53,7 +53,7 @@ public function testDiscoverNothing() public function testDiscoverStores() { $publisher = Publisher::discover()[0]; - $publisher->addFile($this->file); + $publisher->setFiles([])->addFile($this->file); $result = Publisher::discover(); $this->assertSame($publisher, $result[0]); From 5906310002da9f935a5991f094f1aee0e7356830 Mon Sep 17 00:00:00 2001 From: MGatner Date: Sat, 29 May 2021 20:17:28 +0000 Subject: [PATCH 09/12] Fix UG format --- user_guide_src/source/libraries/publisher.rst | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/user_guide_src/source/libraries/publisher.rst b/user_guide_src/source/libraries/publisher.rst index 23f7c2d9e97e..c30bce720815 100644 --- a/user_guide_src/source/libraries/publisher.rst +++ b/user_guide_src/source/libraries/publisher.rst @@ -119,6 +119,8 @@ You want to display a "photo of the day" image on your homepage. You have a feed need to get the actual file into a browsable location in your project at **public/images/daily_photo.jpg**. You can set up :doc:`Custom Command ` to run daily that will handle this for you:: + composer require twbs/bootstrap > php spark publish -... and you'll end up with something like this: +... and you'll end up with something like this:: public/.htaccess public/favicon.ico @@ -240,6 +244,8 @@ You want to allow developers using your popular authentication module the abilit of your Migration, Controller, and Model. You can create your own module "publish" command to inject these components into an application for use:: + Date: Tue, 1 Jun 2021 14:44:25 +0000 Subject: [PATCH 10/12] Implement review changes --- system/Commands/Utilities/Publish.php | 5 ++--- .../Exceptions/PublisherException.php | 21 +++++++++++++++++++ tests/_support/Publishers/TestPublisher.php | 2 -- user_guide_src/source/libraries/index.rst | 1 + user_guide_src/source/libraries/publisher.rst | 18 +++++++++------- 5 files changed, 34 insertions(+), 13 deletions(-) diff --git a/system/Commands/Utilities/Publish.php b/system/Commands/Utilities/Publish.php index b9665286f348..15448fc90db3 100644 --- a/system/Commands/Utilities/Publish.php +++ b/system/Commands/Utilities/Publish.php @@ -49,7 +49,7 @@ class Publish extends BaseCommand * * @var string */ - protected $usage = 'publish [directory]'; + protected $usage = 'publish []'; /** * The Command's arguments @@ -104,9 +104,8 @@ public function run(array $params) foreach ($publisher->getErrors() as $file => $exception) { CLI::write($file); + CLI::error($exception->getMessage()); CLI::newLine(); - - $this->showError($exception); } } } diff --git a/system/Publisher/Exceptions/PublisherException.php b/system/Publisher/Exceptions/PublisherException.php index 81f9bb4b7174..8144fcfeac02 100644 --- a/system/Publisher/Exceptions/PublisherException.php +++ b/system/Publisher/Exceptions/PublisherException.php @@ -13,18 +13,39 @@ use CodeIgniter\Exceptions\FrameworkException; +/** + * Publisher Exception Class + * + * Handles exceptions related to actions taken by a Publisher. + */ class PublisherException extends FrameworkException { + /** + * Throws when a file should be overwritten yet cannot. + * + * @param string $from The source file + * @param string $to The destination file + */ public static function forCollision(string $from, string $to) { return new static(lang('Publisher.collision', [filetype($to), $from, $to])); } + /** + * Throws when an object is expected to be a directory but is not or is missing. + * + * @param string $caller The method causing the exception + */ public static function forExpectedDirectory(string $caller) { return new static(lang('Publisher.expectedDirectory', [$caller])); } + /** + * Throws when an object is expected to be a file but is not or is missing. + * + * @param string $caller The method causing the exception + */ public static function forExpectedFile(string $caller) { return new static(lang('Publisher.expectedFile', [$caller])); diff --git a/tests/_support/Publishers/TestPublisher.php b/tests/_support/Publishers/TestPublisher.php index cf1eefa7ede6..47c987a0285a 100644 --- a/tests/_support/Publishers/TestPublisher.php +++ b/tests/_support/Publishers/TestPublisher.php @@ -9,8 +9,6 @@ final class TestPublisher extends Publisher { /** * Fakes an error on the given file. - * - * @return $this */ public static function setResult(bool $result) { diff --git a/user_guide_src/source/libraries/index.rst b/user_guide_src/source/libraries/index.rst index 3af27e900ce5..9f977e91d604 100644 --- a/user_guide_src/source/libraries/index.rst +++ b/user_guide_src/source/libraries/index.rst @@ -14,6 +14,7 @@ Library Reference honeypot images pagination + publisher security sessions throttler diff --git a/user_guide_src/source/libraries/publisher.rst b/user_guide_src/source/libraries/publisher.rst index c30bce720815..57abcfcd6925 100644 --- a/user_guide_src/source/libraries/publisher.rst +++ b/user_guide_src/source/libraries/publisher.rst @@ -44,11 +44,12 @@ By default the source and destination will be set to ``ROOTPATH`` and ``FCPATH`` easy access to take any file from your project and make it web-accessible. Alternatively you may pass a new source or source and destination into the constructor:: + use CodeIgniter\Publisher\Publisher; + $vendorPublisher = new Publisher(ROOTPATH . 'vendor'); $filterPublisher = new Publisher('/path/to/module/Filters', APPPATH . 'Filters'); -Once the source and destination are set you may start adding relative input files:: - + // Once the source and destination are set you may start adding relative input files $frameworkPublisher = new Publisher(ROOTPATH . 'vendor/codeigniter4/codeigniter4'); // All "path" commands are relative to $source @@ -73,7 +74,7 @@ to their destination(s):: // Place files into their relative $destination directories, overwriting and saving the boolean result $result = $frameworkPublisher->merge(true); -See the Library Reference for a full description of available methods. +See the :ref:`reference` for a full description of available methods. Automation and Discovery ======================== @@ -90,7 +91,7 @@ the powerful ``Autoloader`` to locate any child classes primed for publication:: if ($result === false) { - CLI::write(get_class($publisher) . ' failed to publish!', 'red'); + CLI::error(get_class($publisher) . ' failed to publish!', 'red'); } } @@ -139,7 +140,7 @@ You can set up :doc:`Custom Command ` to run daily that will try { - $publisher->addPath('daily_photo.jpg')->copy($replace = true); + $publisher->addPath('daily_photo.jpg')->copy(true); // `true` to enable overwrites } catch (Throwable $e) { @@ -152,7 +153,7 @@ Now running ``spark publish:daily`` will keep your homepage's image up-to-date. coming from an external API? You can use ``addUri()`` in place of ``addPath()`` to download the remote resource and publish it out instead:: - $publisher->addUri('https://example.com/feeds/daily_photo.jpg')->copy($replace = true); + $publisher->addUri('https://example.com/feeds/daily_photo.jpg')->copy(true); Asset Dependencies Example ========================== @@ -161,8 +162,6 @@ You want to integrate the frontend library "Bootstrap" into your project, but th to keep up with. You can create a publication definition in your project to sync frontend assets by adding extending ``Publisher`` in your project. So **app/Publishers/BootstrapPublisher.php** might look like this:: - merge(true); } + } Now add the dependency via Composer and call ``spark publish`` to run the publication:: @@ -298,6 +298,8 @@ Now when your module users run ``php spark auth:publish`` they will have the fol app/Models/LoginModel.php app/Models/UserModel.php +.. _reference: + ***************** Library Reference ***************** From fb7080790973643a871ede1562eeaacfa21647c8 Mon Sep 17 00:00:00 2001 From: MGatner Date: Tue, 1 Jun 2021 17:23:22 +0000 Subject: [PATCH 11/12] Tweak formatting --- user_guide_src/source/libraries/publisher.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/user_guide_src/source/libraries/publisher.rst b/user_guide_src/source/libraries/publisher.rst index 57abcfcd6925..1cb27b54aa2b 100644 --- a/user_guide_src/source/libraries/publisher.rst +++ b/user_guide_src/source/libraries/publisher.rst @@ -162,6 +162,8 @@ You want to integrate the frontend library "Bootstrap" into your project, but th to keep up with. You can create a publication definition in your project to sync frontend assets by adding extending ``Publisher`` in your project. So **app/Publishers/BootstrapPublisher.php** might look like this:: + addPath('dist') // Indicate we only want the minimized versions - ->retainPattern('*.min.*) + ->retainPattern('*.min.*') // Merge-and-replace to retain the original directory structure ->merge(true); From f33d8809fa96de9fcdcd30042fc4435410abb799 Mon Sep 17 00:00:00 2001 From: MGatner Date: Tue, 8 Jun 2021 16:22:12 +0000 Subject: [PATCH 12/12] Add Publisher restrictions --- app/Config/Publisher.php | 28 ++++ system/Config/Publisher.php | 42 ++++++ system/Language/en/Publisher.php | 8 +- .../Exceptions/PublisherException.php | 22 +++ system/Publisher/Publisher.php | 131 +++++++++++------- tests/_support/Config/Registrar.php | 14 ++ tests/system/Publisher/PublisherInputTest.php | 2 +- .../system/Publisher/PublisherOutputTest.php | 5 +- .../Publisher/PublisherRestrictionsTest.php | 108 +++++++++++++++ .../system/Publisher/PublisherSupportTest.php | 3 +- user_guide_src/source/libraries/publisher.rst | 16 ++- 11 files changed, 325 insertions(+), 54 deletions(-) create mode 100644 app/Config/Publisher.php create mode 100644 system/Config/Publisher.php create mode 100644 tests/system/Publisher/PublisherRestrictionsTest.php diff --git a/app/Config/Publisher.php b/app/Config/Publisher.php new file mode 100644 index 000000000000..2588eea2abac --- /dev/null +++ b/app/Config/Publisher.php @@ -0,0 +1,28 @@ + + */ + public $restrictions = [ + ROOTPATH => '*', + FCPATH => '#\.(?css|js|map|htm?|xml|json|webmanifest|tff|eot|woff?|gif|jpe?g|tiff?|png|webp|bmp|ico|svg)$#i', + ]; +} diff --git a/system/Config/Publisher.php b/system/Config/Publisher.php new file mode 100644 index 000000000000..651600ef9c06 --- /dev/null +++ b/system/Config/Publisher.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Config; + +/** + * Publisher Configuration + * + * Defines basic security restrictions for the Publisher class + * to prevent abuse by injecting malicious files into a project. + */ +class Publisher extends BaseConfig +{ + /** + * A list of allowed destinations with a (pseudo-)regex + * of allowed files for each destination. + * Attempts to publish to directories not in this list will + * result in a PublisherException. Files that do no fit the + * pattern will cause copy/merge to fail. + * + * @var array + */ + public $restrictions = [ + ROOTPATH => '*', + FCPATH => '#\.(?css|js|map|htm?|xml|json|webmanifest|tff|eot|woff?|gif|jpe?g|tiff?|png|webp|bmp|ico|svg)$#i', + ]; + + /** + * Disables Registrars to prevent modules from altering the restrictions. + */ + final protected function registerProperties() + { + } +} diff --git a/system/Language/en/Publisher.php b/system/Language/en/Publisher.php index 934342b4f98c..2d7ae8418b25 100644 --- a/system/Language/en/Publisher.php +++ b/system/Language/en/Publisher.php @@ -11,9 +11,11 @@ // Publisher language settings return [ - 'collision' => 'Publisher encountered an unexpected {0} while copying {1} to {2}.', - 'expectedDirectory' => 'Publisher::{0} expects a valid directory.', - 'expectedFile' => 'Publisher::{0} expects a valid file.', + 'collision' => 'Publisher encountered an unexpected {0} while copying {1} to {2}.', + 'expectedDirectory' => 'Publisher::{0} expects a valid directory.', + 'expectedFile' => 'Publisher::{0} expects a valid file.', + 'destinationNotAllowed' => 'Destination is not on the allowed list of Publisher directories: {0}', + 'fileNotAllowed' => '{0} fails the following restriction for {1}: {2}', // Publish Command 'publishMissing' => 'No Publisher classes detected in {0} across all namespaces.', diff --git a/system/Publisher/Exceptions/PublisherException.php b/system/Publisher/Exceptions/PublisherException.php index 8144fcfeac02..d54420881ec7 100644 --- a/system/Publisher/Exceptions/PublisherException.php +++ b/system/Publisher/Exceptions/PublisherException.php @@ -50,4 +50,26 @@ public static function forExpectedFile(string $caller) { return new static(lang('Publisher.expectedFile', [$caller])); } + + /** + * Throws when given a destination that is not in the list of allowed directories. + * + * @param string $destination + */ + public static function forDestinationNotAllowed(string $destination) + { + return new static(lang('Publisher.destinationNotAllowed', [$destination])); + } + + /** + * Throws when a file fails to match the allowed pattern for its destination. + * + * @param string $file + * @param string $directory + * @param string $pattern + */ + public static function forFileNotAllowed(string $file, string $directory, string $pattern) + { + return new static(lang('Publisher.fileNotAllowed', [$file, $directory, $pattern])); + } } diff --git a/system/Publisher/Publisher.php b/system/Publisher/Publisher.php index 7018df15c7d1..1bfd4b91768d 100644 --- a/system/Publisher/Publisher.php +++ b/system/Publisher/Publisher.php @@ -72,6 +72,14 @@ class Publisher */ private $published = []; + /** + * List of allowed directories and their allowed files regex. + * Restrictions are intentionally private to prevent overriding. + * + * @var array + */ + private $restrictions; + /** * Base path to use for the source. * @@ -134,6 +142,8 @@ final public static function discover(string $directory = 'Publishers'): array * @param string $directory * * @return string + * + * @throws PublisherException */ private static function resolveDirectory(string $directory): string { @@ -152,6 +162,8 @@ private static function resolveDirectory(string $directory): string * @param string $file * * @return string + * + * @throws PublisherException */ private static function resolveFile(string $file): string { @@ -191,7 +203,7 @@ private static function filterFiles(array $files, string $directory): array * * @return string[] */ - private static function matchFiles(array $files, string $pattern) + private static function matchFiles(array $files, string $pattern): array { // Convert pseudo-regex into their true form if (@preg_match($pattern, null) === false) // @phpstan-ignore-line @@ -236,49 +248,6 @@ private static function wipeDirectory(string $directory): void } } - /** - * Copies a file with directory creation and identical file awareness. - * Intentionally allows errors. - * - * @param string $from - * @param string $to - * @param boolean $replace - * - * @return void - * - * @throws PublisherException For unresolvable collisions - */ - private static function safeCopyFile(string $from, string $to, bool $replace): void - { - // Check for an existing file - if (file_exists($to)) - { - // If not replacing or if files are identical then consider successful - if (! $replace || same_file($from, $to)) - { - return; - } - - // If it is a directory then do not try to remove it - if (is_dir($to)) - { - throw PublisherException::forCollision($from, $to); - } - - // Try to remove anything else - unlink($to); - } - - // Make sure the directory exists - if (! is_dir($directory = pathinfo($to, PATHINFO_DIRNAME))) - { - mkdir($directory, 0775, true); - } - - // Allow copy() to throw errors - copy($from, $to); - } - //-------------------------------------------------------------------- /** @@ -293,6 +262,20 @@ public function __construct(string $source = null, string $destination = null) $this->source = self::resolveDirectory($source ?? $this->source); $this->destination = self::resolveDirectory($destination ?? $this->destination); + + // Restrictions are intentionally not injected to prevent overriding + $this->restrictions = config('Publisher')->restrictions; + + // Make sure the destination is allowed + foreach (array_keys($this->restrictions) as $directory) + { + if (strpos($this->destination, $directory) === 0) + { + return; + } + } + + throw PublisherException::forDestinationNotAllowed($this->destination); } /** @@ -314,6 +297,8 @@ public function __destruct() * discovery. * * @return boolean + * + * @throws RuntimeException */ public function publish(): bool { @@ -683,7 +668,7 @@ final public function copy(bool $replace = true): bool try { - self::safeCopyFile($file, $to, $replace); + $this->safeCopyFile($file, $to, $replace); $this->published[] = $to; } catch (Throwable $e) @@ -707,7 +692,7 @@ final public function merge(bool $replace = true): bool { $this->errors = $this->published = []; - // Get the file from source for special handling + // Get the files from source for special handling $sourced = self::filterFiles($this->getFiles(), $this->source); // Handle everything else with a flat copy @@ -722,7 +707,7 @@ final public function merge(bool $replace = true): bool try { - self::safeCopyFile($file, $to, $replace); + $this->safeCopyFile($file, $to, $replace); $this->published[] = $to; } catch (Throwable $e) @@ -733,4 +718,56 @@ final public function merge(bool $replace = true): bool return $this->errors === []; } + + /** + * Copies a file with directory creation and identical file awareness. + * Intentionally allows errors. + * + * @param string $from + * @param string $to + * @param boolean $replace + * + * @return void + * + * @throws PublisherException For collisions and restriction violations + */ + private function safeCopyFile(string $from, string $to, bool $replace): void + { + // Verify this is an allowed file for its destination + foreach ($this->restrictions as $directory => $pattern) + { + if (strpos($to, $directory) === 0 && self::matchFiles([$to], $pattern) === []) + { + throw PublisherException::forFileNotAllowed($from, $directory, $pattern); + } + } + + // Check for an existing file + if (file_exists($to)) + { + // If not replacing or if files are identical then consider successful + if (! $replace || same_file($from, $to)) + { + return; + } + + // If it is a directory then do not try to remove it + if (is_dir($to)) + { + throw PublisherException::forCollision($from, $to); + } + + // Try to remove anything else + unlink($to); + } + + // Make sure the directory exists + if (! is_dir($directory = pathinfo($to, PATHINFO_DIRNAME))) + { + mkdir($directory, 0775, true); + } + + // Allow copy() to throw errors + copy($from, $to); + } } diff --git a/tests/_support/Config/Registrar.php b/tests/_support/Config/Registrar.php index 4135079b73e2..d80522ba615f 100644 --- a/tests/_support/Config/Registrar.php +++ b/tests/_support/Config/Registrar.php @@ -113,4 +113,18 @@ public static function Database() return $config; } + + /** + * Demonstrates Publisher security. + * + * @see PublisherRestrictionsTest::testRegistrarsNotAllowed() + * + * @return array + */ + public static function Publisher() + { + return [ + 'restrictions' => [SUPPORTPATH => '*'], + ]; + } } diff --git a/tests/system/Publisher/PublisherInputTest.php b/tests/system/Publisher/PublisherInputTest.php index 553a92b14c53..e7450f5c02a9 100644 --- a/tests/system/Publisher/PublisherInputTest.php +++ b/tests/system/Publisher/PublisherInputTest.php @@ -1,4 +1,4 @@ -root = vfsStream::setup('root', null, $this->structure); + + // Add root to the list of allowed destinations + config('Publisher')->restrictions[$this->root->url()] = '*'; } //-------------------------------------------------------------------- diff --git a/tests/system/Publisher/PublisherRestrictionsTest.php b/tests/system/Publisher/PublisherRestrictionsTest.php new file mode 100644 index 000000000000..27db2e9429a9 --- /dev/null +++ b/tests/system/Publisher/PublisherRestrictionsTest.php @@ -0,0 +1,108 @@ +assertArrayNotHasKey(SUPPORTPATH, config('Publisher')->restrictions); + } + + public function testImmutableRestrictions() + { + $publisher = new Publisher(); + + // Try to "hack" the Publisher by adding our desired destination to the config + config('Publisher')->restrictions[SUPPORTPATH] = '*'; + + $restrictions = $this->getPrivateProperty($publisher, 'restrictions'); + + $this->assertArrayNotHasKey(SUPPORTPATH, $restrictions); + } + + /** + * @dataProvider fileProvider + */ + public function testDefaultPublicRestrictions(string $path) + { + $publisher = new Publisher(ROOTPATH, FCPATH); + $pattern = config('Publisher')->restrictions[FCPATH]; + + // Use the scratch space to create a file + $file = $publisher->getScratch() . $path; + file_put_contents($file, 'To infinity and beyond!'); + + $result = $publisher->addFile($file)->merge(); + $this->assertFalse($result); + + $errors = $publisher->getErrors(); + $this->assertCount(1, $errors); + $this->assertSame([$file], array_keys($errors)); + + $expected = lang('Publisher.fileNotAllowed', [$file, FCPATH, $pattern]); + $this->assertSame($expected, $errors[$file]->getMessage()); + } + + public function fileProvider() + { + yield 'php' => ['index.php']; + yield 'exe' => ['cat.exe']; + yield 'flat' => ['banana']; + } + + /** + * @dataProvider destinationProvider + */ + public function testDestinations(string $destination, bool $allowed) + { + config('Publisher')->restrictions = [ + APPPATH => '', + FCPATH => '', + SUPPORTPATH . 'Files' => '', + SUPPORTPATH . 'Files/../' => '', + ]; + + if (! $allowed) + { + $this->expectException(PublisherException::class); + $this->expectExceptionMessage(lang('Publisher.destinationNotAllowed', [$destination])); + } + + $publisher = new Publisher(null, $destination); + $this->assertInstanceOf(Publisher::class, $publisher); + } + + public function destinationProvider() + { + return [ + 'explicit' => [ + APPPATH, + true, + ], + 'subdirectory' => [ + APPPATH . 'Config', + true, + ], + 'relative' => [ + SUPPORTPATH . 'Files/able/../', + true, + ], + 'parent' => [ + SUPPORTPATH, + false, + ], + ]; + } +} diff --git a/tests/system/Publisher/PublisherSupportTest.php b/tests/system/Publisher/PublisherSupportTest.php index 2aff0e34d1f6..42f22d721525 100644 --- a/tests/system/Publisher/PublisherSupportTest.php +++ b/tests/system/Publisher/PublisherSupportTest.php @@ -1,4 +1,4 @@ -assertDirectoryExists($directory); + config('Publisher')->restrictions[$directory] = ''; // Allow the directory $publisher = new Publisher($this->directory, $directory); $publisher->wipe(); diff --git a/user_guide_src/source/libraries/publisher.rst b/user_guide_src/source/libraries/publisher.rst index 1cb27b54aa2b..84b430ba144b 100644 --- a/user_guide_src/source/libraries/publisher.rst +++ b/user_guide_src/source/libraries/publisher.rst @@ -107,6 +107,20 @@ Most of the time you will not need to handle your own discovery, just use the pr By default on your class extension ``publish()`` will add all files from your ``$source`` and merge them out to your destination, overwriting on collision. +Security +======== + +In order to prevent modules from injecting malicious code into your projects, ``Publisher`` contains a config file +that defines which directories and file patterns are allowed as destinations. By default, files may only be published +to your project (to prevent access to the rest of the filesystem), and the **public/** folder (``FCPATH``) will only +receive files with the following extensions: +* Web assets: css, scss, js, map +* Non-executable web files: htm, html, xml, json, webmanifest +* Fonts: tff, eot, woff +* Images: gif, jpg, jpeg, tiff, png, webp, bmp, ico, svg + +If you need to add or adjust the security for your project then alter the ``$restrictions`` property of ``Config\Publisher``. + ******** Examples ******** @@ -159,7 +173,7 @@ Asset Dependencies Example ========================== You want to integrate the frontend library "Bootstrap" into your project, but the frequent updates makes it a hassle -to keep up with. You can create a publication definition in your project to sync frontend assets by adding extending +to keep up with. You can create a publication definition in your project to sync frontend assets by extending ``Publisher`` in your project. So **app/Publishers/BootstrapPublisher.php** might look like this::