feat: VivesPOS landing on Winter CMS 1.2 — theme + plugin + Dockerfile
Some checks are pending
Module sub-split / Sub-split (push) Waiting to run

- Base: wintercms/winter branch 1.2 (full framework)
- Theme vivespos: Canvas 7 + Bootstrap 5 CDN, custom CSS
- Layout: deferred GTM/GA4 tracking, JSON-LD SoftwareApplication
- Partials: hero (offline-first), features, modes (offline/nube toggle),
  screenshots, pricing (3 planes), comparison, FAQ, CTA
- Plugin VivesPOS.Site with ContactForm
- Dockerfile: PHP 8.2 Apache, port 80, healthcheck
- Added winter/wn-pages, blog, sitemap, seo plugins
- Active theme set to vivespos
This commit is contained in:
2026-08-21 19:29:00 -06:00
commit 1f72193a64
3266 changed files with 531480 additions and 0 deletions

View File

@@ -0,0 +1,928 @@
<?php namespace System\Classes;
use App;
use Url;
use File;
use Lang;
use Event;
use Cache;
use Route;
use Config;
use Request;
use Response;
use Assetic\Asset\FileAsset;
use Assetic\Asset\AssetCache;
use Assetic\Asset\AssetCollection;
use Assetic\Factory\AssetFactory;
use Assetic\Filter\CssImportFilter;
use Assetic\Filter\CssRewriteFilter;
use Assetic\Filter\JavaScriptMinifierFilter;
use Assetic\Filter\StylesheetMinifyFilter;
use Winter\Storm\Filesystem\PathResolver;
use Winter\Storm\Parse\Assetic\Cache\FilesystemCache;
use Winter\Storm\Parse\Assetic\Filter\LessCompiler;
use Winter\Storm\Parse\Assetic\Filter\ScssCompiler;
use Winter\Storm\Parse\Assetic\Filter\JavascriptImporter;
use System\Helpers\Cache as CacheHelper;
use ApplicationException;
use DateTime;
/**
* Combiner class used for combining JavaScript and StyleSheet files.
*
* This works by taking a collection of asset locations, serializing them,
* then storing them in the session with a unique ID. The ID is then used
* to generate a URL to the `/combine` route via the system controller.
*
* When the combine route is hit, the unique ID is used to serve up the
* assets -- minified, compiled or both. Special E-Tags are used to prevent
* compilation and delivery of cached assets that are unchanged.
*
* Use the `CombineAssets::combine` method to combine your own assets.
*
* The functionality of this class is controlled by these config items:
*
* - cms.enableAssetCache - Cache untouched assets
* - cms.enableAssetMinify - Compress assets using minification
* - cms.enableAssetDeepHashing - Advanced caching of imports
*
* @see System\Classes\SystemController System controller
* @see https://wintercms.com/docs/services/session Session service
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class CombineAssets
{
use \Winter\Storm\Support\Traits\Singleton;
/**
* @var array A list of known JavaScript extensions.
*/
protected static $jsExtensions = ['js'];
/**
* @var array A list of known StyleSheet extensions.
*/
protected static $cssExtensions = ['css', 'less', 'scss', 'sass'];
/**
* @var array Aliases for asset file paths.
*/
protected $aliases = [];
/**
* @var array Bundles that are compiled to the filesystem.
*/
protected $bundles = [];
/**
* @var array Filters to apply to each file.
*/
protected $filters = [];
/**
* @var string The local path context to find assets.
*/
protected $localPath;
/**
* @var string The output folder for storing combined files.
*/
protected $storagePath;
/**
* @var bool Cache untouched files.
*/
public $useCache = false;
/**
* @var bool Compress (minify) asset files.
*/
public $useMinify = false;
/**
* @var bool When true, cache will be busted when an import is modified.
* Enabling this feature will make page loading slower.
*/
public $useDeepHashing = false;
/**
* @var array Cache of registration callbacks.
*/
private static $callbacks = [];
/**
* Constructor
*/
public function init()
{
/*
* Register preferences
*/
$this->useCache = Config::get('cms.enableAssetCache', false);
$this->useMinify = Config::get('cms.enableAssetMinify', null);
$this->useDeepHashing = Config::get('cms.enableAssetDeepHashing', null);
if ($this->useMinify === null) {
$this->useMinify = !Config::get('app.debug', false);
}
if ($this->useDeepHashing === null) {
$this->useDeepHashing = Config::get('app.debug', false);
}
// Constrain asset import directives to known asset trees. Without these
// explicit roots, a writable asset could disclose arbitrary server-readable
// files: `@import (inline) "<path>"` in a .less file (GHSA-58fp-mcx6-7qf9),
// `=include ../../../.env` in a .js file (GHSA-2223-f22x-24cq), or an
// `@import` traversal in a .css file. The asset's own source directory is
// always allowed implicitly; this list adds the cross-tree roots that
// legitimate themes/plugins/modules actually import from (e.g. a plugin
// importing a module asset, or a theme importing its own ../vendor).
$allowedImportRoots = [
themes_path(),
plugins_path(),
base_path('modules'),
];
/*
* Register JavaScript filters
*/
$jsImporter = new JavascriptImporter;
$jsImporter->setAllowedImportRoots($allowedImportRoots);
$this->registerFilter('js', $jsImporter);
/*
* Register CSS filters
*/
$cssImportFilter = new CssImportFilter;
// Assetic's CssImportFilter resolves `@import` targets relative to the source
// with `..` traversal allowed; confine the resolved path to the allowed roots.
$cssImportFilter->setImportValidator(function ($path) use ($allowedImportRoots) {
$resolved = PathResolver::resolve($path);
return $resolved !== false
&& PathResolver::withinAny($resolved, $allowedImportRoots);
});
$this->registerFilter('css', $cssImportFilter);
$this->registerFilter(['css', 'less', 'scss'], new CssRewriteFilter);
$lessCompiler = new LessCompiler;
$lessCompiler->setAllowedImportRoots($allowedImportRoots);
$this->registerFilter('less', $lessCompiler);
$this->registerFilter('scss', new ScssCompiler);
/*
* Minification filters
*/
if ($this->useMinify) {
$this->registerFilter('js', new JavaScriptMinifierFilter);
$this->registerFilter(['css', 'less', 'scss'], new StylesheetMinifyFilter);
}
/*
* Common Aliases
*/
$this->registerAlias('jquery', '~/modules/backend/assets/js/vendor/jquery-and-migrate.min.js');
$this->registerAlias('framework', '~/modules/system/assets/js/framework.js');
$this->registerAlias('framework.extras', '~/modules/system/assets/js/framework.extras.js');
$this->registerAlias('framework.extras.js', '~/modules/system/assets/js/framework.extras.js');
$this->registerAlias('framework.extras', '~/modules/system/assets/css/framework.extras.css');
$this->registerAlias('framework.extras.css', '~/modules/system/assets/css/framework.extras.css');
$snowboardBase = (Config::get('develop.debugSnowboard', false) === true)
? 'snowboard.base.debug.js'
: 'snowboard.base.js';
$this->registerAlias('snowboard.base', '~/modules/system/assets/js/snowboard/build/' . $snowboardBase);
$this->registerAlias('snowboard.attr', '~/modules/system/assets/js/snowboard/build/snowboard.data-attr.js');
$this->registerAlias('snowboard.request', '~/modules/system/assets/js/snowboard/build/snowboard.request.js');
$this->registerAlias('snowboard.extras', '~/modules/system/assets/js/snowboard/build/snowboard.extras.js');
$this->registerAlias('snowboard.extras.css', '~/modules/system/assets/css/snowboard.extras.css');
/*
* Deferred registration
*/
foreach (static::$callbacks as $callback) {
$callback($this);
}
}
/**
* Combines JavaScript or StyleSheet file references
* to produce a page relative URL to the combined contents.
*
* $assets = [
* 'assets/vendor/mustache/mustache.js',
* 'assets/js/vendor/jquery.ui.widget.js',
* 'assets/js/vendor/canvas-to-blob.js',
* ];
*
* CombineAssets::combine($assets, base_path('plugins/acme/blog'));
*
* @param array $assets Collection of assets
* @param string $localPath Prefix all assets with this path (optional)
* @return string URL to contents.
*/
public static function combine($assets = [], $localPath = null)
{
return self::instance()->prepareRequest($assets, $localPath);
}
/**
* Combines a collection of assets files to a destination file
*
* $assets = [
* 'assets/less/header.less',
* 'assets/less/footer.less',
* ];
*
* CombineAssets::combineToFile(
* $assets,
* base_path('themes/website/assets/theme.less'),
* base_path('themes/website')
* );
*
* @param array $assets Collection of assets
* @param string $destination Write the combined file to this location
* @param string $localPath Prefix all assets with this path (optional)
* @return void
*/
public function combineToFile($assets, $destination, $localPath = null)
{
// Disable cache always
$this->storagePath = null;
// Prefix all assets
if ($localPath) {
if (substr($localPath, -1) !== '/') {
$localPath = $localPath.'/';
}
$assets = array_map(function ($asset) use ($localPath) {
if (substr($asset, 0, 1) === '@') {
return $asset;
}
return $localPath.$asset;
}, $assets);
}
list($assets, $extension) = $this->prepareAssets($assets);
$rewritePath = File::localToPublic(dirname($destination));
$combiner = $this->prepareCombiner($assets, $rewritePath);
$contents = $combiner->dump();
File::put($destination, $contents);
}
/**
* Returns the combined contents from a prepared cache identifier.
* @param string $cacheKey Cache identifier.
* @return Response Combined file contents.
*/
public function getContents($cacheKey)
{
$cacheInfo = $this->getCache($cacheKey);
if (!$cacheInfo) {
return Response::make('/* '.e(Lang::get('system::lang.combiner.not_found', ['name' => $cacheKey])).' */', 404);
}
$this->localPath = $cacheInfo['path'];
$this->storagePath = storage_path('cms/combiner/assets');
/*
* Analyse cache information
*/
$lastModifiedTime = gmdate("D, d M Y H:i:s \G\M\T", array_get($cacheInfo, 'lastMod'));
$etag = array_get($cacheInfo, 'etag');
$mime = (array_get($cacheInfo, 'extension') == 'css')
? 'text/css'
: 'application/javascript';
/*
* Set 304 Not Modified header, if necessary
*/
$response = Response::make();
$response->header('Content-Type', $mime);
$response->header('Cache-Control', 'private, max-age=31536000');
$response->setLastModified(new DateTime($lastModifiedTime));
$response->setEtag($etag);
$response->setPublic();
$modified = !$response->isNotModified(App::make('request'));
/*
* Request says response is cached, no code evaluation needed
*/
if ($modified) {
$this->setHashOnCombinerFilters($cacheKey);
$combiner = $this->prepareCombiner($cacheInfo['files']);
$contents = $combiner->dump();
$response->setContent($contents);
}
return $response;
}
/**
* Prepares an array of assets by normalizing the collection
* and processing aliases.
* @param array $assets
* @return array
*/
protected function prepareAssets(array $assets)
{
if (!is_array($assets)) {
$assets = [$assets];
}
/*
* Split assets in to groups.
*/
$combineJs = [];
$combineCss = [];
foreach ($assets as $asset) {
/*
* Allow aliases to go through without an extension
*/
if (substr($asset, 0, 1) == '@') {
$combineJs[] = $asset;
$combineCss[] = $asset;
continue;
}
$extension = File::extension($asset);
if (in_array($extension, self::$jsExtensions)) {
$combineJs[] = $asset;
continue;
}
if (in_array($extension, self::$cssExtensions)) {
$combineCss[] = $asset;
continue;
}
}
/*
* Determine which group of assets to combine.
*/
if (count($combineCss) > count($combineJs)) {
$extension = 'css';
$assets = $combineCss;
}
else {
$extension = 'js';
$assets = $combineJs;
}
/*
* Apply registered aliases
*/
if ($aliasMap = $this->getAliases($extension)) {
foreach ($assets as $key => $asset) {
if (substr($asset, 0, 1) !== '@') {
continue;
}
$_asset = substr($asset, 1);
if (isset($aliasMap[$_asset])) {
$assets[$key] = $aliasMap[$_asset];
}
}
}
return [$assets, $extension];
}
/**
* Combines asset file references of a single type to produce
* a URL reference to the combined contents.
* @param array $assets List of asset files.
* @param string $localPath File extension, used for aesthetic purposes only.
* @return string URL to contents.
*/
protected function prepareRequest(array $assets, $localPath = null)
{
if (substr($localPath, -1) != '/') {
$localPath = $localPath.'/';
}
$this->localPath = $localPath;
$this->storagePath = storage_path('cms/combiner/assets');
list($assets, $extension) = $this->prepareAssets($assets);
/*
* Cache and process
*/
$cacheKey = $this->getCacheKey($assets);
$cacheInfo = $this->useCache ? $this->getCache($cacheKey) : false;
if (!$cacheInfo) {
$this->setHashOnCombinerFilters($cacheKey);
$combiner = $this->prepareCombiner($assets);
if ($this->useDeepHashing) {
$factory = new AssetFactory($this->localPath);
$lastMod = $factory->getLastModified($combiner);
}
else {
$lastMod = $combiner->getLastModified();
}
$cacheInfo = [
'version' => $cacheKey.'-'.$lastMod,
'etag' => $cacheKey,
'lastMod' => $lastMod,
'files' => $assets,
'path' => $this->localPath,
'extension' => $extension
];
$this->putCache($cacheKey, $cacheInfo);
}
return $this->getCombinedUrl($cacheInfo['version']);
}
/**
* Returns the combined contents from a prepared cache identifier.
* @param array $assets List of asset files.
* @param string $rewritePath
* @return string Combined file contents.
*/
protected function prepareCombiner(array $assets, $rewritePath = null)
{
/**
* @event cms.combiner.beforePrepare
* Provides an opportunity to interact with the asset combiner before assets are combined.
* >**NOTE**: Plugin's must be elevated (`$elevated = true` on Plugin.php) to be run on the /combine route and thus listen to this event
*
* Example usage:
*
* Event::listen('cms.combiner.beforePrepare', function ((\System\Classes\CombineAssets) $assetCombiner, (array) $assets) {
* $assetCombiner->registerFilter(...)
* });
*
*/
Event::fire('cms.combiner.beforePrepare', [$this, $assets]);
$files = [];
$filesSalt = null;
foreach ($assets as $asset) {
$filters = $this->getFilters(File::extension($asset)) ?: [];
$path = file_exists($asset) ? $asset : (File::symbolizePath($asset, false) ?: $this->localPath . $asset);
$files[] = new FileAsset($path, $filters, public_path());
$filesSalt .= $this->localPath . $asset;
}
$filesSalt = md5($filesSalt);
$collection = new AssetCollection($files, [], $filesSalt);
$collection->setTargetPath($this->getTargetPath($rewritePath));
if ($this->storagePath === null) {
return $collection;
}
if (!File::isDirectory($this->storagePath)) {
@File::makeDirectory($this->storagePath);
}
$cache = new FilesystemCache($this->storagePath);
$cachedFiles = [];
foreach ($files as $file) {
$cachedFiles[] = new AssetCache($file, $cache);
}
$cachedCollection = new AssetCollection($cachedFiles, [], $filesSalt);
$cachedCollection->setTargetPath($this->getTargetPath($rewritePath));
return $cachedCollection;
}
/**
* Busts the cache based on a different cache key.
* @return void
*/
protected function setHashOnCombinerFilters($hash)
{
$allFilters = array_merge(...array_values($this->getFilters()));
foreach ($allFilters as $filter) {
if (method_exists($filter, 'setHash')) {
$filter->setHash($hash);
}
}
}
/**
* Returns a deep hash on filters that support it.
* @param array $assets List of asset files.
* @return void
*/
protected function getDeepHashFromAssets($assets)
{
$key = '';
$assetFiles = array_map(function ($file) {
return file_exists($file) ? $file : (File::symbolizePath($file, false) ?: $this->localPath . $file);
}, $assets);
foreach ($assetFiles as $file) {
$filters = $this->getFilters(File::extension($file));
foreach ($filters as $filter) {
if (method_exists($filter, 'hashAsset')) {
$key .= $filter->hashAsset($file, $this->localPath);
}
}
}
return $key;
}
/**
* Returns the URL used for accessing the combined files.
* @param string $outputFilename A custom file name to use.
* @return string
*/
protected function getCombinedUrl($outputFilename = 'undefined.css')
{
$combineAction = 'System\Classes\Controller@combine';
$actionExists = Route::getRoutes()->getByAction($combineAction) !== null;
if ($actionExists) {
return Url::action($combineAction, [$outputFilename], false);
}
return '/combine/'.$outputFilename;
}
/**
* Returns the target path for use with the combiner. The target
* path helps generate relative links within CSS.
*
* /combine returns combine/
* /index.php/combine returns index-php/combine/
*
* @param string|null $path
* @return string The new target path
*/
protected function getTargetPath($path = null)
{
if ($path === null) {
$baseUri = substr(Request::getBaseUrl(), strlen(Request::getBasePath()));
$path = $baseUri.'/combine';
}
if (strpos($path, '/') === 0) {
$path = substr($path, 1);
}
$path = str_replace('.', '-', $path).'/';
return $path;
}
//
// Registration
//
/**
* Registers a callback function that defines bundles.
* The callback function should register bundles by calling the manager's
* `registerBundle` method. This instance is passed to the callback
* function as an argument. Usage:
*
* CombineAssets::registerCallback(function ($combiner) {
* $combiner->registerBundle('~/modules/backend/assets/less/winter.less');
* });
*
* @param callable $callback A callable function.
*/
public static function registerCallback(callable $callback)
{
self::$callbacks[] = $callback;
}
//
// Filters
//
/**
* Register a filter to apply to the combining process.
* @param string|array $extension Extension name. Eg: css
* @param object $filter Collection of files to combine.
* @return self
*/
public function registerFilter($extension, $filter)
{
if (is_array($extension)) {
foreach ($extension as $_extension) {
$this->registerFilter($_extension, $filter);
}
return;
}
$extension = strtolower($extension);
if (!isset($this->filters[$extension])) {
$this->filters[$extension] = [];
}
if ($filter !== null) {
$this->filters[$extension][] = $filter;
}
return $this;
}
/**
* Clears any registered filters.
* @param string $extension Extension name. Eg: css
* @return self
*/
public function resetFilters($extension = null)
{
if ($extension === null) {
$this->filters = [];
}
else {
$this->filters[$extension] = [];
}
return $this;
}
/**
* Returns filters.
* @param string $extension Extension name. Eg: css
* @return self
*/
public function getFilters($extension = null)
{
if ($extension === null) {
return $this->filters;
}
if (isset($this->filters[$extension])) {
return $this->filters[$extension];
}
return null;
}
//
// Bundles
//
/**
* Registers bundle.
* @param string|array $files Files to be registered to bundle
* @param string $destination Destination file will be compiled to.
* @param string $extension Extension name. Eg: css
* @return self
*/
public function registerBundle($files, $destination = null, $extension = null)
{
if (!is_array($files)) {
$files = [$files];
}
$firstFile = array_values($files)[0];
if ($extension === null) {
$extension = File::extension($firstFile);
}
$extension = strtolower(trim($extension));
if ($destination === null) {
$file = File::name($firstFile);
$path = dirname($firstFile);
$preprocessors = array_diff(self::$cssExtensions, ['css']);
if (in_array($extension, $preprocessors)) {
$cssPath = $path.'/../css';
if (
in_array(strtolower(basename($path)), $preprocessors) &&
File::isDirectory(File::symbolizePath($cssPath))
) {
$path = $cssPath;
}
$destination = $path.'/'.$file.'.css';
}
else {
$destination = $path.'/'.$file.'-min.'.$extension;
}
}
$this->bundles[$extension][$destination] = $files;
return $this;
}
/**
* Returns bundles.
* @param string $extension Extension name. Eg: css
* @return self
*/
public function getBundles($extension = null)
{
if ($extension === null) {
return $this->bundles;
}
if (isset($this->bundles[$extension])) {
return $this->bundles[$extension];
}
return null;
}
//
// Aliases
//
/**
* Register an alias to use for a longer file reference.
* @param string $alias Alias name. Eg: framework
* @param string $file Path to file to use for alias
* @param string $extension Extension name. Eg: css
* @return self
*/
public function registerAlias($alias, $file, $extension = null)
{
if ($extension === null) {
$extension = File::extension($file);
}
$extension = strtolower($extension);
if (!isset($this->aliases[$extension])) {
$this->aliases[$extension] = [];
}
$this->aliases[$extension][$alias] = $file;
return $this;
}
/**
* Clears any registered aliases.
* @param string $extension Extension name. Eg: css
* @return self
*/
public function resetAliases($extension = null)
{
if ($extension === null) {
$this->aliases = [];
}
else {
$this->aliases[$extension] = [];
}
return $this;
}
/**
* Returns aliases.
* @param string $extension Extension name. Eg: css
* @return self
*/
public function getAliases($extension = null)
{
if ($extension === null) {
return $this->aliases;
}
if (isset($this->aliases[$extension])) {
return $this->aliases[$extension];
}
return null;
}
//
// Cache
//
/**
* Stores information about a asset collection against
* a cache identifier.
* @param string $cacheKey Cache identifier.
* @param array $cacheInfo List of asset files.
* @return bool Successful
*/
protected function putCache($cacheKey, array $cacheInfo)
{
$cacheKey = 'combiner.'.$cacheKey;
if (Cache::has($cacheKey)) {
return false;
}
$this->putCacheIndex($cacheKey);
Cache::forever($cacheKey, base64_encode(serialize($cacheInfo)));
return true;
}
/**
* Look up information about a cache identifier.
* @param string $cacheKey Cache identifier
* @return array Cache information
*/
protected function getCache($cacheKey)
{
$cacheKey = 'combiner.'.$cacheKey;
if (!Cache::has($cacheKey)) {
return false;
}
return @unserialize(@base64_decode(Cache::get($cacheKey)));
}
/**
* Builds a unique string based on assets
* @param array $assets Asset files
* @return string Unique identifier
*/
protected function getCacheKey(array $assets)
{
$cacheKey = $this->localPath . implode('|', $assets);
/*
* Deep hashing
*/
if ($this->useDeepHashing) {
$cacheKey .= $this->getDeepHashFromAssets($assets);
}
$dataHolder = (object) ['key' => $cacheKey];
/**
* @event cms.combiner.getCacheKey
* Provides an opportunity to modify the asset combiner's cache key
*
* Example usage:
*
* Event::listen('cms.combiner.getCacheKey', function ((\System\Classes\CombineAssets) $assetCombiner, (stdClass) $dataHolder) {
* $dataHolder->key = rand();
* });
*
*/
Event::fire('cms.combiner.getCacheKey', [$this, $dataHolder]);
$cacheKey = $dataHolder->key;
return md5($cacheKey);
}
/**
* Resets the combiner cache
* @return void
*/
public static function resetCache()
{
if (Cache::has('combiner.index')) {
$index = (array) @unserialize(@base64_decode(Cache::get('combiner.index'))) ?: [];
foreach ($index as $cacheKey) {
Cache::forget($cacheKey);
}
Cache::forget('combiner.index');
}
CacheHelper::instance()->clearCombiner();
}
/**
* Adds a cache identifier to the index store used for
* performing a reset of the cache.
* @param string $cacheKey Cache identifier
* @return bool Returns false if identifier is already in store
*/
protected function putCacheIndex($cacheKey)
{
$index = [];
if (Cache::has('combiner.index')) {
$index = (array) @unserialize(@base64_decode(Cache::get('combiner.index'))) ?: [];
}
if (in_array($cacheKey, $index)) {
return false;
}
$index[] = $cacheKey;
Cache::forever('combiner.index', base64_encode(serialize($index)));
return true;
}
}

View File

@@ -0,0 +1,130 @@
<?php namespace System\Classes;
/**
* Composer manager
*
* This class manages composer packages introduced by plugins. Each loaded
* package is added to a global pool to ensure a package is not loaded
* twice by the composer instance introduced by a plugin. This class
* is used as a substitute for the vendor/autoload.php file.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class ComposerManager
{
use \Winter\Storm\Support\Traits\Singleton;
protected $namespacePool = [];
protected $psr4Pool = [];
protected $classMapPool = [];
protected $includeFilesPool = [];
/**
* @var Composer\Autoload\ClassLoader The primary composer instance.
*/
protected $loader;
public function init()
{
$this->loader = include base_path() .'/vendor/autoload.php';
$this->preloadPools();
}
protected function preloadPools()
{
$this->classMapPool = array_fill_keys(array_keys($this->loader->getClassMap()), true);
$this->namespacePool = array_fill_keys(array_keys($this->loader->getPrefixes()), true);
$this->psr4Pool = array_fill_keys(array_keys($this->loader->getPrefixesPsr4()), true);
$this->includeFilesPool = $this->preloadIncludeFilesPool();
}
protected function preloadIncludeFilesPool()
{
$result = [];
$vendorPath = base_path() .'/vendor';
if (file_exists($file = $vendorPath . '/composer/autoload_files.php')) {
$includeFiles = require $file;
foreach ($includeFiles as $includeFile) {
$relativeFile = $this->stripVendorDir($includeFile, $vendorPath);
$result[$relativeFile] = true;
}
}
return $result;
}
/**
* Similar function to including vendor/autoload.php.
* @param string $vendorPath Absoulte path to the vendor directory.
* @return void
*/
public function autoload($vendorPath)
{
$dir = $vendorPath . '/composer';
if (file_exists($file = $dir . '/autoload_namespaces.php')) {
$map = require $file;
foreach ($map as $namespace => $path) {
if (isset($this->namespacePool[$namespace])) {
continue;
}
$this->loader->set($namespace, $path);
$this->namespacePool[$namespace] = true;
}
}
if (file_exists($file = $dir . '/autoload_psr4.php')) {
$map = require $file;
foreach ($map as $namespace => $path) {
if (isset($this->psr4Pool[$namespace])) {
continue;
}
$this->loader->setPsr4($namespace, $path);
$this->psr4Pool[$namespace] = true;
}
}
if (file_exists($file = $dir . '/autoload_classmap.php')) {
$classMap = require $file;
if ($classMap) {
$classMapDiff = array_diff_key($classMap, $this->classMapPool);
$this->loader->addClassMap($classMapDiff);
$this->classMapPool += array_fill_keys(array_keys($classMapDiff), true);
}
}
if (file_exists($file = $dir . '/autoload_files.php')) {
$includeFiles = require $file;
foreach ($includeFiles as $includeFile) {
$relativeFile = $this->stripVendorDir($includeFile, $vendorPath);
if (isset($this->includeFilesPool[$relativeFile])) {
continue;
}
require $includeFile;
$this->includeFilesPool[$relativeFile] = true;
}
}
}
/**
* Removes the vendor directory from a path.
* @param string $path
* @return string
*/
protected function stripVendorDir($path, $vendorDir)
{
$path = realpath($path);
$vendorDir = realpath($vendorDir);
if (strpos($path, $vendorDir) === 0) {
$path = substr($path, strlen($vendorDir));
}
return $path;
}
}

View File

@@ -0,0 +1,83 @@
<?php namespace System\Classes;
use View;
use Config;
use Cms\Classes\Theme;
use Cms\Classes\Router;
use Cms\Classes\Controller as CmsController;
use Winter\Storm\Exception\ErrorHandler as ErrorHandlerBase;
use Winter\Storm\Exception\SystemException;
use Symfony\Component\HttpFoundation\Response;
/**
* System Error Handler, this class handles application exception events.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class ErrorHandler extends ErrorHandlerBase
{
/**
* @inheritDoc
*/
// public function handleException(Exception $proposedException)
// {
// // The Twig runtime error is not very useful
// if (
// $proposedException instanceof \Twig\Error\RuntimeError &&
// ($previousException = $proposedException->getPrevious()) &&
// (!$previousException instanceof CmsException)
// ) {
// $proposedException = $previousException;
// }
// return parent::handleException($proposedException);
// }
/**
* Looks up an error page using the CMS route "/error". If the route does not
* exist, this function will use the error view found in the Cms module.
* @return mixed Error page contents.
*/
public function handleCustomError()
{
if (Config::get('app.debug', false)) {
return null;
}
if (class_exists(Theme::class) && in_array('Cms', Config::get('cms.loadModules', []))) {
$theme = Theme::getActiveTheme();
$router = new Router($theme);
// Use the default view if no "/error" URL is found.
if (!$router->findByUrl('/error')) {
return View::make('cms::error');
}
// Route to the CMS error page.
$controller = new CmsController($theme);
$result = $controller->run('/error');
} else {
$result = View::make('system::error');
}
// Extract content from response object
if ($result instanceof Response) {
$result = $result->getContent();
}
return $result;
}
/**
* Displays the detailed system exception page.
* @return View Object containing the error page.
*/
public function handleDetailedError($exception)
{
// Ensure System view path is registered
View::addNamespace('system', base_path().'/modules/system/views');
return View::make('system::exception', ['exception' => $exception]);
}
}

View File

@@ -0,0 +1,193 @@
<?php namespace System\Classes;
use ApplicationException;
use Config;
use Winter\Storm\Filesystem\Filesystem;
use Winter\Storm\Halcyon\Datasource\FileDatasource;
/**
* Stores the file manifest for this Winter CMS installation.
*
* This manifest is a file checksum of all files within this Winter CMS installation. When compared to the source
* manifest, this allows us to determine the current installation's build number.
*
* @package winter\wn-system-module
* @author Ben Thomson
*/
class FileManifest
{
/**
* @var string Root folder of this installation.
*/
protected $root;
/**
* @var array Modules to store in manifest.
*/
protected $modules = ['system', 'backend', 'cms'];
/**
* @var array Files cache.
*/
protected $files = [];
/**
* @var array File extensions to normalize newlines for
*/
protected $normalizeExtensions = [
'css',
'htm',
'html',
'js',
'json',
'less',
'md',
'php',
'sass',
'scss',
'svg',
'txt',
'xml',
'yaml',
];
/**
* Constructor.
*/
public function __construct(?string $root = null, ?array $modules = null)
{
$this->setRoot($root ?? base_path());
$this->setModules($modules ?? Config::get('cms.loadModules', ['System', 'Backend', 'Cms']));
}
/**
* Sets the root folder.
*
* @throws ApplicationException If the specified root does not exist.
*/
public function setRoot(string $root): static
{
if (is_string($root)) {
$this->root = realpath($root);
if ($this->root === false || !is_dir($this->root)) {
throw new ApplicationException(
'Invalid root specified for the file manifest.'
);
}
}
return $this;
}
/**
* Sets the modules.
*/
public function setModules(array $modules): static
{
$this->modules = array_map(function ($module) {
return strtolower($module);
}, $modules);
return $this;
}
/**
* Gets a list of files and their corresponding hashsums.
*/
public function getFiles(): array
{
if (count($this->files)) {
return $this->files;
}
$files = [];
foreach ($this->modules as $module) {
$path = $this->root . '/modules/' . $module;
if (!is_dir($path)) {
continue;
}
foreach ($this->findFiles($path) as $file) {
$files[$this->getFilename($file)] = hash('sha3-256', $this->normalizeFileContents($file));
}
}
return $this->files = $files;
}
/**
* Gets the checksum of a specific install.
*/
public function getModuleChecksums(): array
{
if (!count($this->files)) {
$this->getFiles();
}
$modules = [];
foreach ($this->modules as $module) {
$modules[$module] = '';
}
foreach ($this->files as $path => $hash) {
// Determine module
$module = explode('/', $path)[2];
$modules[$module] .= $hash;
}
return array_map(function ($moduleSum) {
return hash('sha3-256', $moduleSum);
}, $modules);
}
/**
* Finds all files within the path.
*/
protected function findFiles(string $basePath): array
{
$datasource = new FileDatasource($basePath, new Filesystem);
$files = array_map(function ($path) use ($basePath) {
return $basePath . '/' . $path;
}, array_keys($datasource->getAvailablePaths()));
// Ensure files are sorted so they are in a consistent order, no matter the way the OS returns the file list.
sort($files, SORT_NATURAL);
return $files;
}
/**
* Returns the filename without the root.
*/
protected function getFilename(string $file): string
{
return substr($file, strlen($this->root));
}
/**
* Normalises the file contents, irrespective of OS.
*/
protected function normalizeFileContents(string $file): string
{
if (!is_file($file)) {
return '';
}
$contents = file_get_contents($file);
// Replace Windows newlines in text files with Unix newlines
if (
PHP_EOL === "\r\n"
&& in_array(pathinfo($file, PATHINFO_EXTENSION), $this->normalizeExtensions)
) {
$contents = str_replace(PHP_EOL, "\n", $contents);
}
return $contents;
}
}

View File

@@ -0,0 +1,938 @@
<?php namespace System\Classes;
use Url;
use Crypt;
use Cache;
use Event;
use Config;
use Storage;
use Exception;
use SystemException;
use File as FileHelper;
use Illuminate\Filesystem\FilesystemAdapter;
use System\Models\File as SystemFileModel;
use Winter\Storm\Database\Attach\File as FileModel;
use Winter\Storm\Database\Attach\Resizer as DefaultResizer;
/**
* Image Resizing class used for resizing any image resources accessible
* to the application.
*
* This works by accepting a variety of image sources and normalizing the
* pipeline for storing the desired resizing configuration and then
* deferring the actual resizing of the images until requested by the browser.
*
* When the resizer route is hit, the configuration is retrieved from the cache
* and used to generate the desired image and then redirect to the generated images
* static path to minimize the load on the server. Future loads of the image are
* automatically pointed to the static URL of the resized image without even hitting
* the resizer route.
*
* The functionality of this class is controlled by these config items:
*
* - cms.storage.resized.disk - The disk to store resized images on
* - cms.storage.resized.folder - The folder on the disk to store resized images in
* - cms.storage.resized.path - The public path to the resized images as returned
* by the storage disk's URL method, used to identify
* already resized images
*
* @see System\Classes\SystemController System controller
* @see System\Twig\Extension Twig filters for this class defined
* @package winter\wn-system-module
* @author Luke Towers
*/
class ImageResizer
{
/**
* The cache key prefix for resizer configs
*/
public const CACHE_PREFIX = 'system.resizer.';
/**
* @var array Available sources to get images from
*/
protected static $availableSources = [];
/**
* @var string Unique identifier for the current configuration
*/
protected $identifier = null;
/**
* @var array Image source data ['disk' => string, 'path' => string, 'source' => string]
*/
protected $image = [];
/**
* @var FileModel The instance of the FileModel for the source image
*/
protected $fileModel = null;
/**
* @var integer Desired width
*/
protected $width = 0;
/**
* @var integer Desired height
*/
protected $height = 0;
/**
* @var array Image resizing configuration data
*/
protected $options = [];
/**
* Prepare the resizer instance
*
* @param mixed $image Supported values below:
* ['disk' => FilesystemAdapter, 'path' => string, 'source' => string, 'fileModel' => FileModel|void],
* instance of Winter\Storm\Database\Attach\File,
* string containing URL or path accessible to the application's filesystem manager
* @param integer|string|bool|null $width Desired width of the resized image
* @param integer|string|bool|null $height Desired height of the resized image
* @param array|null $options Array of options to pass to the resizer
*/
public function __construct($image, $width = 0, $height = 0, $options = [])
{
$this->image = static::normalizeImage($image);
$this->width = (int) (($width === 'auto') ? 0 : $width);
$this->height = (int) (($height === 'auto') ? 0 : $height);
$this->options = array_merge($this->getDefaultOptions(), $options);
}
/**
* Get the default options for the resizer
*/
public function getDefaultOptions(): array
{
// Default options for the built in resizing processor
$defaultOptions = [
'mode' => 'auto',
'offset' => [0, 0],
'sharpen' => 0,
'interlace' => false,
'quality' => 90,
'extension' => $this->getExtension(),
];
/**
* @event system.resizer.getDefaultOptions
* Provides an opportunity to modify the default options used when generating image resize requests
*
* Example usage:
*
* Event::listen('system.resizer.getDefaultOptions', function ((array) &$defaultOptions)) {
* $defaultOptions['background'] = '#f2f2f2';
* });
*
*/
Event::fire('system.resizer.getDefaultOptions', [&$defaultOptions]);
return $defaultOptions;
}
/**
* Get the available sources for processing image resize requests from
*/
public static function getAvailableSources(): array
{
if (!empty(static::$availableSources)) {
return static::$availableSources;
}
$sources = [
'themes' => [
'disk' => 'system',
'folder' => config('cms.themesPathLocal', base_path('themes')),
'path' => rtrim(config('cms.themesPath', '/themes'), '/'),
],
'plugins' => [
'disk' => 'system',
'folder' => config('cms.pluginsPathLocal', base_path('plugins')),
'path' => rtrim(config('cms.pluginsPath', '/plugins'), '/'),
],
'resized' => [
'disk' => config('cms.storage.resized.disk', 'local'),
'folder' => config('cms.storage.resized.folder', 'resized'),
'path' => rtrim(config('cms.storage.resized.path', '/storage/app/resized'), '/'),
],
'media' => [
'disk' => config('cms.storage.media.disk', 'local'),
'folder' => config('cms.storage.media.folder', 'media'),
'path' => rtrim(config('cms.storage.media.path', '/storage/app/media'), '/'),
],
'modules' => [
'disk' => 'system',
'folder' => base_path('modules'),
'path' => '/modules',
],
'filemodel' => [
'disk' => config('cms.storage.uploads.disk', 'local'),
'folder' => config('cms.storage.uploads.folder', 'uploads'),
'path' => rtrim(config('cms.storage.uploads.path', '/storage/app/uploads'), '/'),
],
];
/**
* @event system.resizer.getAvailableSources
* Provides an opportunity to modify the sources available for processing resize requests from
*
* Example usage:
*
* Event::listen('system.resizer.getAvailableSources', function ((array) &$sources)) {
* $sources['custom'] = [
* 'disk' => 'custom',
* 'folder' => 'relative/path/on/disk',
* 'path' => 'publicly/accessible/path',
* ];
* });
*
*/
Event::fire('system.resizer.getAvailableSources', [&$sources]);
return static::$availableSources = $sources;
}
/**
* Flushes the local sources cache.
*/
public static function flushAvailableSources(): void
{
if (empty(static::$availableSources)) {
return;
}
static::$availableSources = [];
}
/**
* Get the current config
*/
public function getConfig(): array
{
$disk = $this->image['disk'];
// Normalize local disk adapters with symlinked paths to their target path
// to support atomic deployments where the base application path changes
// each deployment but the realpath of the storage directory does not
if (FileHelper::isLocalDisk($disk)) {
$realPath = realpath($disk->getPathPrefix());
if ($realPath) {
$disk->setPathPrefix($realPath);
}
}
// Include last modified time to tie generated images to the source image
$mtime = $disk->lastModified($this->image['path']);
// Handle disks that can't be serialized by referencing them by their
// filesystems.php config name
try {
serialize($disk);
} catch (Exception $ex) {
$disk = Storage::identify($disk);
}
$config = [
'image' => [
'disk' => $disk,
'path' => $this->image['path'],
'mtime' => $mtime,
'source' => $this->image['source'],
],
'width' => $this->width,
'height' => $this->height,
'options' => $this->options,
];
if ($fileModel = $this->getFileModel()) {
$config['image']['fileModel'] = [
'class' => get_class($fileModel),
'key' => $fileModel->getKey(),
];
}
return $config;
}
/**
* Process the resize request
*/
public function resize(): void
{
if ($this->isResized()) {
return;
}
// Get the details for the target image
list($disk, $path) = $this->getTargetDetails();
// Copy the image to be resized to the temp directory
$tempPath = $this->getLocalTempPath();
try {
/**
* @event system.resizer.processResize
* Halting event that enables replacement of the resizing process. There should only ever be
* one listener handling this event per project at most, as other listeners would be ignored.
*
* Example usage:
*
* Event::listen('system.resizer.processResize', function ((\System\Classes\ImageResizer) $resizer, (string) $localTempPath) {
* // Get the resizing configuration
* $config = $resizer->getConfig();
*
* // Resize the image
* $resizedImageContents = My\Custom\Resizer::resize($localTempPath, $config['width], $config['height'], $config['options']);
*
* // Place the resized image in the correct location for the resizer to finish processing it
* file_put_contents($localTempPath, $resizedImageContents);
*
* // Prevent any other resizing replacer logic from running
* return true;
* });
*
*/
$processed = Event::fire('system.resizer.processResize', [$this, $tempPath], true);
if (!$processed) {
// Process the resize with the default image resizer
DefaultResizer::open($tempPath)
->resize($this->width, $this->height, $this->options)
->save($tempPath);
}
/**
* @event system.resizer.afterResize
* Enables post processing of resized images after they've been resized before the
* resizing process is finalized (ex. adding watermarks, further optimizing, etc)
*
* Example usage:
*
* Event::listen('system.resizer.afterResize', function ((\System\Classes\ImageResizer) $resizer, (string) $localTempPath) {
* // Get the resized image data
* $resizedImageContents = file_get_contents($localTempPath);
*
* // Post process the image
* $processedContents = TinyPNG::optimize($resizedImageContents);
*
* // Place the processed image in the correct location for the resizer to finish processing it
* file_put_contents($localTempPath, $processedContents);
* });
*
*/
Event::fire('system.resizer.afterResize', [$this, $tempPath]);
// Store the resized image
$disk->put($path, file_get_contents($tempPath));
// Clean up
unlink($tempPath);
} catch (Exception $ex) {
// Clean up in case of any issues
unlink($tempPath);
// Pass the exception up
throw $ex;
}
}
/**
* Process the crop request
*/
public function crop(): void
{
if ($this->isResized()) {
return;
}
// Get the details for the target image
list($disk, $path) = $this->getTargetDetails();
// Copy the image to be resized to the temp directory
$tempPath = $this->getLocalTempPath();
try {
/**
* @event system.resizer.processCrop
* Halting event that enables replacement of the cropping process. There should only ever be
* one listener handling this event per project at most, as other listeners would be ignored.
*
* Example usage:
*
* Event::listen('system.resizer.processCrop', function ((\System\Classes\ImageResizer) $resizer, (string) $localTempPath) {
* // Get the resizing configuration
* $config = $resizer->getConfig();
*
* // Resize the image
* $resizedImageContents = My\Custom\Resizer::crop($localTempPath, $config['width], $config['height'], $config['options']);
*
* // Place the resized image in the correct location for the resizer to finish processing it
* file_put_contents($localTempPath, $resizedImageContents);
*
* // Prevent any other resizing replacer logic from running
* return true;
* });
*
*/
$processed = Event::fire('system.resizer.processCrop', [$this, $tempPath], true);
if (!$processed) {
// Process the crop with the default image resizer
DefaultResizer::open($tempPath)
->crop(
$this->options['offset'][0],
$this->options['offset'][1],
$this->width,
$this->height
)
->save($tempPath);
}
/**
* @event system.resizer.afterCrop
* Enables post processing of cropped images after they've been cropped before the
* cropping process is finalized (ex. adding watermarks, further optimizing, etc)
*
* Example usage:
*
* Event::listen('system.resizer.afterCrop', function ((\System\Classes\ImageResizer) $resizer, (string) $localTempPath) {
* // Get the resized image data
* $croppedImageContents = file_get_contents($localTempPath);
*
* // Post process the image
* $processedContents = TinyPNG::optimize($croppedImageContents);
*
* // Place the processed image in the correct location for the resizer to finish processing it
* file_put_contents($localTempPath, $processedContents);
* });
*
*/
Event::fire('system.resizer.afterCrop', [$this, $tempPath]);
// Store the resized image
$disk->put($path, file_get_contents($tempPath));
// Clean up
unlink($tempPath);
} catch (Exception $ex) {
// Clean up in case of any issues
unlink($tempPath);
// Pass the exception up
throw $ex;
}
}
/**
* Get the internal temporary drirectory and ensure it exists
*/
public function getTempPath(): string
{
$path = temp_path() . '/resizer';
if (!FileHelper::isDirectory($path)) {
FileHelper::makeDirectory($path, 0777, true, true);
}
return $path;
}
/**
* Stores the current source image in the temp directory and returns the path to it
*
* @param string $path The path to suffix the temp directory path with, defaults to $identifier.$ext
*/
protected function getLocalTempPath($path = null): string
{
if (!is_null($path) && is_string($path)) {
$tempPath = $this->getTempPath() . '/' . $path;
} else {
$tempPath = $this->getTempPath() . '/' . $this->getIdentifier() . '.' . $this->getExtension();
}
if (!file_exists($tempPath)) {
FileHelper::put($tempPath, $this->getSourceFileContents());
}
return $tempPath;
}
/**
* Returns the file extension.
*/
public function getExtension(): string
{
return FileHelper::extension($this->image['path']);
}
/**
* Get the contents of the image file to be resized
*/
public function getSourceFileContents()
{
return $this->image['disk']->get($this->image['path']);
}
/**
* Gets the current fileModel associated with the source image if one exists
*/
public function getFileModel(): ?FileModel
{
if ($this->fileModel) {
return $this->fileModel;
}
if ($this->image['source'] === 'filemodel') {
if ($this->image['fileModel'] instanceof FileModel) {
$this->fileModel = $this->image['fileModel'];
} else {
$this->fileModel = $this->image['fileModel']['class']::findOrFail($this->image['fileModel']['key']);
}
}
return $this->fileModel;
}
/**
* Get the default disk used to store processed images
*/
public static function getDefaultDisk(): \Illuminate\Contracts\Filesystem\Filesystem
{
return Storage::disk(Config::get('cms.storage.resized.disk', 'local'));
}
/**
* Get the disk instance for image that is currently being processed
*/
public function getDisk(): \Illuminate\Contracts\Filesystem\Filesystem
{
return ($this->image['source'] === 'filemodel' && $fileModel = $this->getFileModel())
? $fileModel->getDisk()
: static::getDefaultDisk();
}
/**
* Get the details for the target image
*
* @return array [FilesystemAdapter $disk, (string) $path]
*/
protected function getTargetDetails(): array
{
if ($this->image['source'] === 'filemodel' && $fileModel = $this->getFileModel()) {
return [
$this->getDisk(),
$fileModel->getDiskPath($fileModel->getThumbFilename($this->width, $this->height, $this->options)),
];
}
return [
$this->getDisk(),
$this->getPathToResizedImage(),
];
}
/**
* Get the reference to the resized image if the requested resize exists
*/
public function isResized(): bool
{
// Get the details for the target image
list($disk, $path) = $this->getTargetDetails();
// Return true if the path is a file and it exists on the target disk
return !empty(FileHelper::extension($path)) && $disk->exists($path);
}
/**
* Get the path of the resized image
*/
public function getPathToResizedImage(): string
{
// Generate the unique file identifier for the resized image
$fileIdentifier = hash_hmac('sha1', serialize($this->getConfig()), Crypt::getKey());
// Generate the filename for the resized image
$name = pathinfo($this->image['path'], PATHINFO_FILENAME) . "_resized_$fileIdentifier.{$this->options['extension']}";
// Generate the path to the containing folder for the resized image
$folder = implode('/', array_slice(str_split(str_limit($fileIdentifier, 9), 3), 0, 3));
// Generate and return the full path
return Config::get('cms.storage.resized.folder', 'resized') . '/' . $folder . '/' . $name;
}
/**
* Gets the current useful URL to the resized image
* (resizer if not resized, resized image directly if resized)
*/
public function getUrl(): string
{
if ($this->isResized()) {
return $this->getResizedUrl();
} else {
return $this->getResizerUrl();
}
}
/**
* Get the URL to the system resizer route for this instance's configuration
*/
public function getResizerUrl(): string
{
// Slashes in URL params have to be double encoded to survive Laravel's router
// @see https://github.com/octobercms/october/issues/3592#issuecomment-671017380
$resizedUrl = rawurlencode(rawurlencode($this->getResizedUrl()));
// Double-encode dots (rawurlencode() skips them) to avoid issues in certain NGINX
// configurations where dots may trigger asset-serving rules, resulting in 404 errors
$resizedUrl = str_replace('.', '%252E', $resizedUrl);
// Get the current configuration's identifier
$identifier = $this->getIdentifier();
// Store the current configuration
$this->storeConfig();
$url = "/resizer/$identifier/$resizedUrl";
if (Config::get('cms.linkPolicy', 'detect') === 'force') {
$url = Url::to($url);
}
return $url;
}
/**
* Get the URL to the resized image
*/
public function getResizedUrl(): string
{
$url = '';
if ($this->image['source'] === 'filemodel') {
$model = $this->getFileModel();
$thumbFile = $model->getThumbFilename($this->width, $this->height, $this->options);
$url = $model->getPath($thumbFile);
} else {
$resizedDisk = Storage::disk(Config::get('cms.storage.resized.disk', 'local'));
$url = $resizedDisk->url($this->getPathToResizedImage());
}
// Ensure that a properly encoded URL is returned
$segments = explode('/', $url);
$lastSegment = array_pop($segments);
$url = implode('/', $segments) . '/' . rawurlencode(rawurldecode($lastSegment));
if (Config::get('cms.linkPolicy', 'detect') === 'force') {
$url = Url::to($url);
}
return $url;
}
/**
* Normalize the provided input into information that the resizer can work with
*
* @param mixed $image Supported values below:
* ['disk' => FilesystemAdapter, 'path' => string, 'source' => string, 'fileModel' => FileModel|void],
* instance of Winter\Storm\Database\Attach\File,
* string containing URL or path accessible to the application's filesystem manager
* @throws SystemException If the image was unable to be identified
* @return array Array containing the disk, path, source, and fileModel if applicable
* ['disk' => FilesystemAdapter, 'path' => string, 'source' => string, 'fileModel' => FileModel|void]
*/
public static function normalizeImage($image): array
{
$disk = null;
$path = null;
$selectedSource = null;
$fileModel = null;
// Process an array
if (is_array($image) && !empty($image['disk']) && !empty($image['path']) && !empty($image['source'])) {
$disk = $image['disk'];
$path = $image['path'];
$selectedSource = $image['source'];
// Handle disks that couldn't be serialized
if (is_string($disk)) {
// Handle disks of type "system" (the local file system the application is running on)
if ($disk === 'system') {
Config::set('filesystems.disks.system', [
'driver' => 'local',
'root' => base_path(),
]);
// Regenerate the path relative to the newly defined "system" disk
$path = str_after($path, static::normalizePath(base_path()) . '/');
}
$disk = Storage::disk($disk);
}
// Verify that the source file exists
if (empty(FileHelper::extension($path)) || !$disk->exists($path)) {
$disk = null;
$path = null;
$selectedSource = null;
}
if (!empty($image['fileModel'])) {
$fileModel = $image['fileModel'];
}
// Process a FileModel
} elseif ($image instanceof FileModel) {
$disk = $image->getDisk();
$path = $image->getDiskPath();
$selectedSource = 'filemodel';
$fileModel = $image;
// Verify that the source file exists
if (empty(FileHelper::extension($path)) || !$disk->exists($path)) {
$disk = null;
$path = null;
$selectedSource = null;
$fileModel = null;
}
// Process a string
} elseif (is_string($image)) {
// Parse the provided image path into a filesystem ready relative path
$relativePath = static::normalizePath(rawurldecode(parse_url($image, PHP_URL_PATH)));
// Loop through the sources available to the application to pull from
// to identify the source most likely to be holding the image
$resizeSources = static::getAvailableSources();
foreach ($resizeSources as $source => $details) {
// Normalize the source path
$sourcePath = static::normalizePath(rawurldecode(parse_url($details['path'], PHP_URL_PATH)));
// Identify if the current source is a match
if (starts_with($relativePath, $sourcePath)) {
// Attempt to handle FileModel URLs passed as strings
if ($source === 'filemodel') {
$diskName = pathinfo($relativePath, PATHINFO_BASENAME);
$model = SystemFileModel::where('disk_name', $diskName)->first();
if ($model && $image = static::normalizeImage($model)) {
$disk = $image['disk'];
$path = $image['path'];
$selectedSource = $image['source'];
$fileModel = $image['fileModel'];
}
// Stop any further path processing from happening on filemodel sources
break;
}
// Generate a path relative to the selected disk
$path = static::normalizePath($details['folder']) . '/' . str_after($relativePath, $sourcePath . '/');
// Handle disks of type "system" (the local file system the application is running on)
if ($details['disk'] === 'system') {
Config::set('filesystems.disks.system', [
'driver' => 'local',
'root' => base_path(),
]);
// Regenerate the path relative to the newly defined "system" disk
$path = str_after($path, static::normalizePath(base_path()) . '/');
}
$disk = Storage::disk($details['disk']);
// Verify that the file exists before exiting the identification process
if (!empty(FileHelper::extension($path)) && $disk->exists($path)) {
$selectedSource = $source;
break;
} else {
$disk = null;
$path = null;
continue;
}
}
}
}
if (!$disk || !$path || !$selectedSource || (!in_array(strtolower(FileHelper::extension($path)), ['jpg', 'jpeg', 'png', 'webp', 'gif', 'avif']))) {
if (is_object($image)) {
$image = get_class($image);
}
throw new SystemException("Unable to process the provided image: " . e(var_export($image, true)));
}
$data = [
'disk' => $disk,
'path' => $path,
'source' => $selectedSource,
];
if ($fileModel) {
$data['fileModel'] = $fileModel;
}
return $data;
}
/**
* Normalize the provided path to Unix style directory seperators to ensure
* that path manipulation operations succeed regardless of environment
*
* NOTE: Can't use Winter\Storm\FileSystem\PathResolver because it prepends
* the current working directory to relative paths
*/
protected static function normalizePath(string $path): string
{
return str_replace('\\', '/', $path);
}
/**
* Check if the provided identifier looks like a valid identifier
*
* @param string $id
* @return bool
*/
public static function isValidIdentifier($id): bool
{
return is_string($id) && ctype_alnum($id) && strlen($id) === 40;
}
/**
* Gets the identifier for provided resizing configuration
*
* @return string 40 character string used as a unique reference to the provided configuration
*/
public function getIdentifier(): string
{
if ($this->identifier) {
return $this->identifier;
}
// Generate & return the identifier
return $this->identifier = hash_hmac('sha1', $this->getResizedUrl(), Crypt::getKey());
}
/**
* Stores the resizer configuration if the resizing hasn't been completed yet
*/
public function storeConfig(): void
{
// If the image hasn't been resized yet, then store the config data for the resizer to use
if (!$this->isResized()) {
Cache::put(static::CACHE_PREFIX . $this->getIdentifier(), $this->getConfig());
}
}
/**
* Instantiate a resizer instance from the provided identifier
*
* @param string $identifier The 40 character cache identifier for the desired resizer configuration
* @throws SystemException If the identifier is unable to be loaded
*/
public static function fromIdentifier(string $identifier): self
{
$cacheKey = static::CACHE_PREFIX . $identifier;
// Attempt to retrieve the resizer configuration
$config = Cache::get($cacheKey, null);
// Validate that the desired config was able to be loaded
if (empty($config)) {
throw new SystemException("Unable to retrieve the configuration for " . e($identifier));
}
$resizer = new static($config['image'], $config['width'], $config['height'], $config['options']);
// Remove the data from the cache only after successfully instantiating the resizer
// in order to make it easier to debug should any issues occur during the instantiation
// since the browser will "steal" the configuration with the first request it makes
// if we pull the configuration data out immediately.
Cache::forget($cacheKey);
return $resizer;
}
/**
* Check the provided encoded URL to verify its signature and return the decoded URL
*
* @return string|null Returns null if the provided value was invalid
*/
public static function getValidResizedUrl(string $identifier, string $encodedUrl): ?string
{
// Slashes in URL params have to be double encoded to survive Laravel's router
// @see https://github.com/octobercms/october/issues/3592#issuecomment-671017380
$decodedUrl = rawurldecode($encodedUrl);
$url = null;
// The identifier should be the signed version of the decoded URL
if (static::isValidIdentifier($identifier) && $identifier === hash_hmac('sha1', $decodedUrl, Crypt::getKey())) {
$url = $decodedUrl;
}
return $url;
}
/**
* Converts supplied input into a URL that will return the desired resized image
*
* @param mixed $image Supported values below:
* ['disk' => FilesystemAdapter, 'path' => string, 'source' => string, 'fileModel' => FileModel|void],
* instance of Winter\Storm\Database\Attach\File,
* string containing URL or path accessible to the application's filesystem manager
* @param integer|string|bool|null $width Desired width of the resized image
* @param integer|string|bool|null $height Desired height of the resized image
* @param array|null $options Array of options to pass to the resizer
* @throws Exception If the provided image was unable to be processed
*/
public static function filterGetUrl($image, $width = null, $height = null, $options = []): string
{
// Attempt to process the provided image
try {
$resizer = new static($image, $width, $height, $options);
} catch (SystemException $ex) {
// Ignore processing this URL if the resizer is unable to identify it
if (is_scalar($image) || empty($image)) {
return (string) $image;
} elseif ($image instanceof FileModel) {
return $image->getPath();
} else {
throw $ex;
}
}
return $resizer->getUrl();
}
/**
* Gets the dimensions of the provided image file
* NOTE: Doesn't currently support being passed a FileModel image that has already been resized
*
* @param mixed $image Supported values below:
* ['disk' => FilesystemAdapter, 'path' => string, 'source' => string, 'fileModel' => FileModel|void],
* instance of Winter\Storm\Database\Attach\File,
* string containing URL or path accessible to the application's filesystem manager
* @throws SystemException If the provided input was unable to be processed
*/
public static function filterGetDimensions($image): array
{
$resizer = new static($image);
return Cache::rememberForever(static::CACHE_PREFIX . 'dimensions.' . $resizer->getIdentifier(), function () use ($resizer) {
// Prepare the local file for assessment
$tempPath = $resizer->getLocalTempPath();
$dimensions = [];
// Attempt to get the image size
try {
$size = getimagesize($tempPath);
$dimensions['width'] = $size[0];
$dimensions['height'] = $size[1];
} catch (\Exception $ex) {
@unlink($tempPath);
throw $ex;
}
// Cleanup afterwards
@unlink($tempPath);
return $dimensions;
});
}
}

View File

@@ -0,0 +1,403 @@
<?php namespace System\Classes;
use App;
use Markdown;
use System\Models\MailPartial;
use System\Models\MailTemplate;
use System\Models\MailBrandSetting;
use System\Helpers\View as ViewHelper;
use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles;
/**
* This class manages Mail sending functions
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class MailManager
{
use \Winter\Storm\Support\Traits\Singleton;
/**
* @var array Cache of registration callbacks.
*/
protected $callbacks = [];
/**
* @var array A cache of customised mail templates.
*/
protected $templateCache = [];
/**
* @var array List of registered templates in the system
*/
protected $registeredTemplates;
/**
* @var array List of registered partials in the system
*/
protected $registeredPartials;
/**
* @var array List of registered layouts in the system
*/
protected $registeredLayouts;
/**
* @var bool Internal marker for rendering mode
*/
protected $isHtmlRenderMode = false;
/**
* Same as `addContentToMailer` except with raw content.
*
* @return bool
*/
public function addRawContentToMailer($message, $content, $data)
{
$template = new MailTemplate;
$template->fillFromContent($content);
$this->addContentToMailerInternal($message, $template, $data);
return true;
}
/**
* This function hijacks the `addContent` method of the `Winter\Storm\Mail\Mailer`
* class, using the `mailer.beforeAddContent` event.
*
* @param \Illuminate\Mail\Message $message
* @param string $code
* @param array $data
* @param bool $plainOnly Add only plain text content to the message
* @return bool
*/
public function addContentToMailer($message, $code, $data, $plainOnly = false)
{
// We only handle mail template names as a string, let the caller handle the content if we receive anything else
if (!is_string($code)) {
return false;
}
if (isset($this->templateCache[$code])) {
$template = $this->templateCache[$code];
}
else {
$this->templateCache[$code] = $template = MailTemplate::findOrMakeTemplate($code);
}
if (!$template) {
return false;
}
$this->addContentToMailerInternal($message, $template, $data, $plainOnly);
return true;
}
/**
* Internal method used to share logic between `addRawContentToMailer` and `addContentToMailer`
*
* @param \Illuminate\Mail\Message $message
* @param string $template
* @param array $data
* @param bool $plainOnly Add only plain text content to the message
* @return void
*/
protected function addContentToMailerInternal($message, $template, $data, $plainOnly = false)
{
/*
* Inject global view variables
*/
$globalVars = ViewHelper::getGlobalVars();
if (!empty($globalVars)) {
$data = (array) $data + $globalVars;
}
/*
* Subject
*/
$symfonyMessage = $message->getSymfonyMessage();
if (empty($symfonyMessage->getSubject())) {
$message->subject($this->renderTwig($template->subject, $data));
}
$data += [
'subject' => $symfonyMessage->getSubject()
];
if (!$plainOnly) {
/*
* HTML contents
*/
$html = $this->renderTemplate($template, $data);
$message->html($html);
}
/*
* Text contents
*/
$text = $this->renderTextTemplate($template, $data);
$message->text($text);
}
//
// Rendering
//
/**
* Render the Markdown template into HTML.
*
* @param string $content
* @param array $data
* @return string
*/
public function render($content, $data = [])
{
if (!$content) {
return '';
}
$html = $this->renderTwig($content, $data);
$html = Markdown::parseSafe($html);
return $html;
}
public function renderTemplate($template, $data = [])
{
$this->isHtmlRenderMode = true;
$html = $this->render($template->content_html, $data);
$css = MailBrandSetting::renderCss();
$disableAutoInlineCss = false;
if ($template->layout) {
$disableAutoInlineCss = array_get($template->layout->options, 'disable_auto_inline_css', $disableAutoInlineCss);
$html = $this->renderTwig($template->layout->content_html, [
'content' => $html,
'css' => $template->layout->content_css,
'brandCss' => $css,
] + (array) $data);
$css .= PHP_EOL . $template->layout->content_css;
}
if (!$disableAutoInlineCss) {
$html = (new CssToInlineStyles)->convert($html, $css);
}
return $html;
}
/**
* Render the Markdown template into text.
* @param $content
* @param array $data
* @return string
*/
public function renderText($content, $data = [])
{
if (!$content) {
return '';
}
$text = $this->renderTwig($content, $data);
$text = html_entity_decode(preg_replace("/[\r\n]{2,}/", "\n\n", $text), ENT_QUOTES, 'UTF-8');
return $text;
}
public function renderTextTemplate($template, $data = [])
{
$this->isHtmlRenderMode = false;
$templateText = $template->content_text;
if (!strlen($template->content_text)) {
$templateText = $template->content_html;
}
$text = $this->renderText($templateText, $data);
if ($template->layout) {
$text = $this->renderTwig($template->layout->content_text, [
'content' => $text
] + (array) $data);
}
return $text;
}
public function renderPartial($code, array $params = [])
{
if (!$partial = MailPartial::findOrMakePartial($code)) {
return '<!-- Missing partial: '.$code.' -->';
}
if ($this->isHtmlRenderMode) {
$content = $partial->content_html;
}
else {
$content = $partial->content_text ?: $partial->content_html;
}
if (!strlen(trim($content))) {
return '';
}
return $this->renderTwig($content, $params);
}
/**
* Internal helper for rendering Twig using the mailer Twig environment
*/
protected function renderTwig(string $content, array $data = []): string
{
return App::make('twig.environment.mailer')
->createTemplate($content)
->render($data);
}
//
// Registration
//
/**
* Loads registered mail templates from modules and plugins
* @return void
*/
public function loadRegisteredTemplates()
{
foreach ($this->callbacks as $callback) {
$callback($this);
}
$plugins = PluginManager::instance()->getPlugins();
foreach ($plugins as $pluginId => $pluginObj) {
$layouts = $pluginObj->registerMailLayouts();
if (is_array($layouts)) {
$this->registerMailLayouts($layouts);
}
$templates = $pluginObj->registerMailTemplates();
if (is_array($templates)) {
$this->registerMailTemplates($templates);
}
$partials = $pluginObj->registerMailPartials();
if (is_array($partials)) {
$this->registerMailPartials($partials);
}
}
}
/**
* Returns a list of the registered templates.
* @return array
*/
public function listRegisteredTemplates()
{
if ($this->registeredTemplates === null) {
$this->loadRegisteredTemplates();
}
return $this->registeredTemplates;
}
/**
* Returns a list of the registered partials.
* @return array
*/
public function listRegisteredPartials()
{
if ($this->registeredPartials === null) {
$this->loadRegisteredTemplates();
}
return $this->registeredPartials;
}
/**
* Returns a list of the registered layouts.
* @return array
*/
public function listRegisteredLayouts()
{
if ($this->registeredLayouts === null) {
$this->loadRegisteredTemplates();
}
return $this->registeredLayouts;
}
/**
* Registers a callback function that defines mail templates.
* The callback function should register templates by calling the manager's
* registerMailTemplates() function. This instance is passed to the
* callback function as an argument. Usage:
*
* MailManager::registerCallback(function ($manager) {
* $manager->registerMailTemplates([...]);
* });
*
* @param callable $callback A callable function.
*/
public function registerCallback(callable $callback)
{
$this->callbacks[] = $callback;
}
/**
* Registers mail views and manageable templates.
*/
public function registerMailTemplates(array $definitions)
{
if (!$this->registeredTemplates) {
$this->registeredTemplates = [];
}
// Prior sytax where (key) code => (value) description
if (!isset($definitions[0])) {
$definitions = array_keys($definitions);
}
$definitions = array_combine($definitions, $definitions);
$this->registeredTemplates = $definitions + $this->registeredTemplates;
}
/**
* Registers mail views and manageable layouts.
*/
public function registerMailPartials(array $definitions)
{
if (!$this->registeredPartials) {
$this->registeredPartials = [];
}
$this->registeredPartials = $definitions + $this->registeredPartials;
}
/**
* Registers mail views and manageable layouts.
*/
public function registerMailLayouts(array $definitions)
{
if (!$this->registeredLayouts) {
$this->registeredLayouts = [];
}
$this->registeredLayouts = $definitions + $this->registeredLayouts;
}
}

View File

@@ -0,0 +1,379 @@
<?php namespace System\Classes;
use System\Twig\Extension as SystemTwigExtension;
use System\Twig\GetAttrAdjuster;
use System\Twig\Loader as SystemTwigLoader;
use System\Twig\SecurityPolicy as TwigSecurityPolicy;
use Twig\Environment as TwigEnvironment;
use Twig\Extension\SandboxExtension;
use Twig\Loader\LoaderInterface;
use Twig\TokenParser\AbstractTokenParser as TwigTokenParser;
use Twig\TwigFilter as TwigSimpleFilter;
use Twig\TwigFunction as TwigSimpleFunction;
use Winter\Storm\Exception\SystemException;
use Winter\Storm\Support\Str;
/**
* This class manages Twig functions, token parsers and filters.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class MarkupManager
{
use \Winter\Storm\Support\Traits\Singleton;
const EXTENSION_FILTER = 'filters';
const EXTENSION_FUNCTION = 'functions';
const EXTENSION_TOKEN_PARSER = 'tokens';
/**
* @var array Cache of registration callbacks.
*/
protected $callbacks = [];
/**
* @var array Globally registered extension items
*/
protected $items;
/**
* @var \System\Classes\PluginManager
*/
protected $pluginManager;
/**
* Initialize this singleton.
*/
protected function init()
{
$this->pluginManager = PluginManager::instance();
}
/**
* Make an instance of the base TwigEnvironment to extend further
*/
public static function makeBaseTwigEnvironment(?LoaderInterface $loader = null, array $options = []): TwigEnvironment
{
if (!$loader) {
$loader = new SystemTwigLoader();
}
$options = array_merge([
'auto_reload' => true,
], $options);
$twig = new TwigEnvironment($loader, $options);
$twig->addExtension(new SystemTwigExtension);
$twig->addExtension(new SandboxExtension(new TwigSecurityPolicy, true));
$twig->addNodeVisitor(new GetAttrAdjuster);
return $twig;
}
/**
* Loads all of the registered Twig extensions
*/
protected function loadExtensions(): void
{
// Load Module extensions
foreach ($this->callbacks as $callback) {
$callback($this);
}
// Load Plugin extensions
$plugins = $this->pluginManager->getPlugins();
foreach ($plugins as $id => $plugin) {
$items = $plugin->registerMarkupTags();
if (!is_array($items)) {
continue;
}
foreach ($items as $type => $definitions) {
if (!is_array($definitions)) {
continue;
}
$this->registerExtensions($type, $definitions);
}
}
}
/**
* Registers a callback function that defines simple Twig extensions.
* The callback function should register menu items by calling the manager's
* `registerFunctions`, `registerFilters`, `registerTokenParsers` function.
* The manager instance is passed to the callback function as an argument. Usage:
*
* MarkupManager::registerCallback(function ($manager) {
* $manager->registerFilters([...]);
* $manager->registerFunctions([...]);
* $manager->registerTokenParsers([...]);
* });
*
*/
public function registerCallback(callable $callback): void
{
$this->callbacks[] = $callback;
}
/**
* Registers the Twig extension items.
* $type must be one of self::EXTENSION_TOKEN_PARSER, self::EXTENSION_FILTER, or self::EXTENSION_FUNCTION
* $definitions is of the format of [$extensionName => $associativeExtensionOptions]
*/
public function registerExtensions(string $type, array $definitions): void
{
if ($this->items === null) {
$this->items = [];
}
if (!array_key_exists($type, $this->items)) {
$this->items[$type] = [];
}
foreach ($definitions as $name => $definition) {
switch ($type) {
case self::EXTENSION_TOKEN_PARSER:
$this->items[$type][] = $definition;
break;
case self::EXTENSION_FILTER:
case self::EXTENSION_FUNCTION:
$this->items[$type][$name] = $definition;
break;
}
}
}
/**
* Registers a Twig Filter
*/
public function registerFilters(array $definitions): void
{
$this->registerExtensions(self::EXTENSION_FILTER, $definitions);
}
/**
* Registers a Twig Function
*/
public function registerFunctions(array $definitions): void
{
$this->registerExtensions(self::EXTENSION_FUNCTION, $definitions);
}
/**
* Registers a Twig Token Parser
*/
public function registerTokenParsers(array $definitions): void
{
$this->registerExtensions(self::EXTENSION_TOKEN_PARSER, $definitions);
}
/**
* Returns a list of the registered Twig extensions of a type.
* @param $type string The Twig extension type
* @return array
*/
public function listExtensions($type)
{
$results = [];
if ($this->items === null) {
$this->loadExtensions();
}
if (isset($this->items[$type]) && is_array($this->items[$type])) {
$results = $this->items[$type];
}
return $results;
}
/**
* Returns a list of the registered Twig filters.
* @return array
*/
public function listFilters()
{
return $this->listExtensions(self::EXTENSION_FILTER);
}
/**
* Returns a list of the registered Twig functions.
* @return array
*/
public function listFunctions()
{
return $this->listExtensions(self::EXTENSION_FUNCTION);
}
/**
* Returns a list of the registered Twig token parsers.
* @return array
*/
public function listTokenParsers()
{
return $this->listExtensions(self::EXTENSION_TOKEN_PARSER);
}
/**
* Makes a set of Twig functions for use in a twig extension.
* @param array $functions Current collection
* @return array
*/
public function makeTwigFunctions($functions = [])
{
$defaultOptions = ['is_safe' => ['html']];
if (!is_array($functions)) {
$functions = [];
}
foreach ($this->listFunctions() as $name => $callable) {
$options = [];
if (is_array($callable) && isset($callable['options'])) {
$options = $callable['options'];
$callable = $callable['callable'] ?? $callable[0];
if (isset($options['is_safe']) && !is_array($options['is_safe'])) {
if (is_string($options['is_safe'])) {
$options['is_safe'] = [$options['is_safe']];
} else {
$options['is_safe'] = [];
}
}
}
$options = array_merge($defaultOptions, $options);
/*
* Handle a wildcard function
*/
if (strpos($name, '*') !== false && $this->isWildCallable($callable)) {
$callable = function ($name) use ($callable) {
$arguments = array_slice(func_get_args(), 1);
$method = $this->isWildCallable($callable, Str::camel($name));
return call_user_func_array($method, $arguments);
};
}
if (!is_callable($callable)) {
throw new SystemException(sprintf('The markup function (%s) for %s is not callable.', json_encode($callable), $name));
}
$functions[] = new TwigSimpleFunction($name, $callable, $options);
}
return $functions;
}
/**
* Makes a set of Twig filters for use in a twig extension.
* @param array $filters Current collection
* @return array
*/
public function makeTwigFilters($filters = [])
{
$defaultOptions = ['is_safe' => ['html']];
if (!is_array($filters)) {
$filters = [];
}
foreach ($this->listFilters() as $name => $callable) {
$options = [];
if (is_array($callable) && isset($callable['options'])) {
$options = $callable['options'];
$callable = $callable['callable'] ?? $callable[0];
if (isset($options['is_safe']) && !is_array($options['is_safe'])) {
if (is_string($options['is_safe'])) {
$options['is_safe'] = [$options['is_safe']];
} else {
$options['is_safe'] = [];
}
}
}
$options = array_merge($defaultOptions, $options);
/*
* Handle a wildcard function
*/
if (strpos($name, '*') !== false && $this->isWildCallable($callable)) {
$callable = function ($name) use ($callable) {
$arguments = array_slice(func_get_args(), 1);
$method = $this->isWildCallable($callable, Str::camel($name));
return call_user_func_array($method, $arguments);
};
}
if (!is_callable($callable)) {
throw new SystemException(sprintf('The markup filter (%s) for %s is not callable.', json_encode($callable), $name));
}
$filters[] = new TwigSimpleFilter($name, $callable, $options);
}
return $filters;
}
/**
* Makes a set of Twig token parsers for use in a twig extension.
* @param array $parsers Current collection
* @return array
*/
public function makeTwigTokenParsers($parsers = [])
{
if (!is_array($parsers)) {
$parsers = [];
}
$extraParsers = $this->listTokenParsers();
foreach ($extraParsers as $obj) {
if (!$obj instanceof TwigTokenParser) {
continue;
}
$parsers[] = $obj;
}
return $parsers;
}
/**
* Tests if a callable type contains a wildcard, also acts as a
* utility to replace the wildcard with a string.
* @param callable $callable
* @param string|bool $replaceWith
* @return mixed
*/
protected function isWildCallable($callable, $replaceWith = false)
{
$isWild = false;
if (is_string($callable) && strpos($callable, '*') !== false) {
$isWild = $replaceWith ? str_replace('*', $replaceWith, $callable) : true;
}
if (is_array($callable)) {
if (is_string($callable[0]) && strpos($callable[0], '*') !== false) {
if ($replaceWith) {
$isWild = $callable;
$isWild[0] = str_replace('*', $replaceWith, $callable[0]);
}
else {
$isWild = true;
}
}
if (!empty($callable[1]) && strpos($callable[1], '*') !== false) {
if ($replaceWith) {
$isWild = $isWild ?: $callable;
$isWild[1] = str_replace('*', $replaceWith, $callable[1]);
}
else {
$isWild = true;
}
}
}
return $isWild;
}
}

View File

@@ -0,0 +1,837 @@
<?php
namespace System\Classes;
use ApplicationException;
use Cache;
use Config;
use Illuminate\Filesystem\FilesystemAdapter;
use Lang;
use Storage;
use SystemException;
use Url;
use Winter\Storm\Filesystem\Definitions as FileDefinitions;
use Winter\Storm\Support\Str;
use Winter\Storm\Support\Svg;
/**
* Provides abstraction level for the Media Library operations.
* Implements the library caching features and security checks.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class MediaLibrary
{
use \Winter\Storm\Support\Traits\Singleton;
const SORT_BY_TITLE = 'title';
const SORT_BY_SIZE = 'size';
const SORT_BY_MODIFIED = 'modified';
const SORT_DIRECTION_ASC = 'asc';
const SORT_DIRECTION_DESC = 'desc';
/**
* @var string Cache key
*/
protected $cacheKey = 'system-media-library-contents';
/**
* @var string Relative or absolute URL of the Library root folder.
*/
protected $storagePath;
/**
* @var string The root Library folder path.
*/
protected $storageFolder;
/**
* @var mixed A reference to the Media Library disk.
*/
protected $storageDisk;
/**
* @var array Contains a list of files and directories to ignore.
* The list can be customized with cms.storage.media.ignore configuration option.
*/
protected $ignoreNames;
/**
* @var array Contains a list of regex patterns to ignore in files and directories.
* The list can be customized with cms.storage.media.ignorePatterns configuration option.
*/
protected $ignorePatterns;
/**
* @var int Cache for the storage folder name length.
*/
protected $storageFolderNameLength;
/**
* Initialize this singleton.
*/
protected function init()
{
$this->storageFolder = self::validatePath(Config::get('cms.storage.media.folder', 'media'), true);
$this->storagePath = rtrim(Config::get('cms.storage.media.path', '/storage/app/media'), '/');
$this->ignoreNames = Config::get('cms.storage.media.ignore', FileDefinitions::get('ignoreFiles'));
$this->ignorePatterns = Config::get('cms.storage.media.ignorePatterns', ['^\..*']);
$this->storageFolderNameLength = strlen($this->storageFolder);
}
/**
* Set the cache key
*
* @param string $cacheKey The key to set as the cache key for this instance
*/
public function setCacheKey($cacheKey)
{
$this->cacheKey = $cacheKey;
}
/**
* Get the cache key
*
* @return string The cache key to set as the cache key for this instance
*/
public function getCacheKey()
{
return $this->cacheKey;
}
/**
* Returns a list of folders and files in a Library folder.
*
* @param string $folder Specifies the folder path relative the the Library root.
* @param mixed $sortBy Determines the sorting preference.
* Supported values are 'title', 'size', 'lastModified' (see SORT_BY_XXX class constants), FALSE (to disable sorting), or an associative array with a 'by' key and a 'direction' key: ['by' => SORT_BY_XXX, 'direction' => SORT_DIRECTION_XXX].
* @param string $filter Determines the document type filtering preference.
* Supported values are 'image', 'video', 'audio', 'document' (see FILE_TYPE_XXX constants of MediaLibraryItem class).
* @param boolean $ignoreFolders Determines whether folders should be suppressed in the result list.
* @return array Returns an array of MediaLibraryItem objects.
*/
public function listFolderContents($folder = '/', $sortBy = 'title', $filter = null, $ignoreFolders = false)
{
$folder = self::validatePath($folder);
$fullFolderPath = $this->getMediaPath($folder);
/*
* Try to load the contents from cache
*/
$cached = Cache::get($this->cacheKey, false);
$cached = $cached ? @unserialize(@base64_decode($cached)) : [];
if (!is_array($cached)) {
$cached = [];
}
if (array_key_exists($fullFolderPath, $cached)) {
$folderContents = $cached[$fullFolderPath];
}
else {
$folderContents = $this->scanFolderContents($fullFolderPath);
$cached[$fullFolderPath] = $folderContents;
$expiresAt = now()->addMinutes(Config::get('cms.storage.media.ttl', 10));
Cache::put(
$this->cacheKey,
base64_encode(serialize($cached)),
$expiresAt
);
}
/*
* Sort the result and combine the file and folder lists
*/
if ($sortBy !== false) {
$this->sortItemList($folderContents['files'], $sortBy);
$this->sortItemList($folderContents['folders'], $sortBy);
}
$this->filterItemList($folderContents['files'], $filter);
if (!$ignoreFolders) {
$folderContents = array_merge($folderContents['folders'], $folderContents['files']);
}
else {
$folderContents = $folderContents['files'];
}
return $folderContents;
}
/**
* Finds files in the Library.
* @param string $searchTerm Specifies the search term.
* @param mixed $sortBy Determines the sorting preference.
* Supported values are 'title', 'size', 'lastModified' (see SORT_BY_XXX class constants), FALSE (to disable sorting), or an associative array with a 'by' key and a 'direction' key: ['by' => SORT_BY_XXX, 'direction' => SORT_DIRECTION_XXX].
* @param string $filter Determines the document type filtering preference.
* Supported values are 'image', 'video', 'audio', 'document' (see FILE_TYPE_XXX constants of MediaLibraryItem class).
* @return array Returns an array of MediaLibraryItem objects.
*/
public function findFiles($searchTerm, $sortBy = 'title', $filter = null)
{
$words = explode(' ', Str::lower($searchTerm));
$result = [];
$findInFolder = function ($folder) use (&$findInFolder, $words, &$result, $sortBy, $filter) {
$folderContents = $this->listFolderContents($folder, $sortBy, $filter);
foreach ($folderContents as $item) {
if ($item->type == MediaLibraryItem::TYPE_FOLDER) {
$findInFolder($item->path);
}
elseif ($this->pathMatchesSearch($item->path, $words)) {
$result[] = $item;
}
}
};
$findInFolder('/');
/*
* Sort the result
*/
if ($sortBy !== false) {
$this->sortItemList($result, $sortBy);
}
return $result;
}
/**
* Deletes a file from the Library.
* @param array $paths A list of file paths relative to the Library root to delete.
*/
public function deleteFiles($paths)
{
$fullPaths = [];
foreach ($paths as $path) {
$path = self::validatePath($path);
$fullPaths[] = $this->getMediaPath($path);
}
return $this->getStorageDisk()->delete($fullPaths);
}
/**
* Deletes a folder from the Library.
* @param string $path Specifies the folder path relative to the Library root.
*/
public function deleteFolder($path)
{
$path = self::validatePath($path);
$fullPaths = $this->getMediaPath($path);
return $this->getStorageDisk()->deleteDirectory($fullPaths);
}
/**
* Determines if a file with the specified path exists in the library.
* @param string $path Specifies the file path relative the the Library root.
* @return boolean Returns TRUE if the file exists.
*/
public function exists($path)
{
$path = self::validatePath($path);
$fullPath = $this->getMediaPath($path);
return $this->getStorageDisk()->exists($fullPath);
}
/**
* Determines if a folder with the specified path exists in the library.
* @param string $path Specifies the folder path relative the the Library root.
* @return boolean Returns TRUE if the folder exists.
*/
public function folderExists($path)
{
$folderName = basename($path);
$folderPath = dirname($path);
$path = self::validatePath($folderPath);
$fullPath = $this->getMediaPath($path);
$folders = $this->getStorageDisk()->directories($fullPath);
foreach ($folders as $folder) {
if (basename($folder) == $folderName) {
return true;
}
}
return false;
}
/**
* Returns a list of all directories in the Library, optionally excluding some of them.
* @param array $exclude A list of folders to exclude from the result list.
* The folder paths should be specified relative to the Library root.
* @return array
*/
public function listAllDirectories($exclude = [])
{
$fullPath = $this->getMediaPath('/');
$folders = $this->getStorageDisk()->allDirectories($fullPath);
$folders = array_unique($folders, SORT_LOCALE_STRING);
$result = [];
foreach ($folders as $folder) {
$folder = $this->getMediaRelativePath($folder);
if (!strlen($folder)) {
$folder = '/';
}
if (Str::startsWith($folder, $exclude)) {
continue;
}
if (!$this->isVisible($folder)) {
$exclude[] = $folder . '/';
continue;
}
$result[] = $folder;
}
if (!in_array('/', $result)) {
array_unshift($result, '/');
}
return $result;
}
/**
* Returns a file contents.
* @param string $path Specifies the file path relative the the Library root.
* @return string Returns the file contents
*/
public function get($path)
{
$path = self::validatePath($path);
$fullPath = $this->getMediaPath($path);
return $this->getStorageDisk()->get($fullPath);
}
/**
* Puts a file to the library.
* @param string $path Specifies the file path relative the the Library root.
* @param string $contents Specifies the file contents.
* @return boolean
*/
public function put($path, $contents)
{
$path = self::validatePath($path);
$fullPath = $this->getMediaPath($path);
return $this->getStorageDisk()->put($fullPath, $contents);
}
/**
* Moves a file to another location.
* @param string $oldPath Specifies the original path of the file.
* @param string $newPath Specifies the new path of the file.
* @return boolean
*/
public function moveFile($oldPath, $newPath, $isRename = false)
{
$oldPath = self::validatePath($oldPath);
$fullOldPath = $this->getMediaPath($oldPath);
$newPath = self::validatePath($newPath);
$fullNewPath = $this->getMediaPath($newPath);
// If the file extension is changed to SVG, ensure that it has been sanitized
$oldExt = pathinfo($oldPath, PATHINFO_EXTENSION);
$newExt = pathinfo($newPath, PATHINFO_EXTENSION);
if ($oldExt !== $newExt && strtolower($newExt) === 'svg') {
$contents = $this->getStorageDisk()->get($fullOldPath);
$contents = Svg::sanitize($contents);
$this->getStorageDisk()->put($fullOldPath, $contents);
}
return $this->getStorageDisk()->move($fullOldPath, $fullNewPath);
}
/**
* Copies a folder.
* @param string $originalPath Specifies the original path of the folder.
* @param string $newPath Specifies the new path of the folder.
* @return boolean
*/
public function copyFolder($originalPath, $newPath)
{
$disk = $this->getStorageDisk();
$copyDirectory = function ($srcPath, $destPath) use (&$copyDirectory, $disk) {
$srcPath = self::validatePath($srcPath);
$fullSrcPath = $this->getMediaPath($srcPath);
$destPath = self::validatePath($destPath);
$fullDestPath = $this->getMediaPath($destPath);
if (!$disk->makeDirectory($fullDestPath)) {
return false;
}
$folderContents = $this->scanFolderContents($fullSrcPath);
foreach ($folderContents['folders'] as $dirInfo) {
if (!$copyDirectory($dirInfo->path, $destPath.'/'.basename($dirInfo->path))) {
return false;
}
}
foreach ($folderContents['files'] as $fileInfo) {
$fullFileSrcPath = $this->getMediaPath($fileInfo->path);
if (!$disk->copy($fullFileSrcPath, $fullDestPath.'/'.basename($fileInfo->path))) {
return false;
}
}
return true;
};
return $copyDirectory($originalPath, $newPath);
}
/**
* Moves a folder.
* @param string $originalPath Specifies the original path of the folder.
* @param string $newPath Specifies the new path of the folder.
* @return boolean
*/
public function moveFolder($originalPath, $newPath)
{
if (Str::lower($originalPath) !== Str::lower($newPath)) {
// If there is no risk that the directory was renamed
// by just changing the letter case in the name -
// copy the directory to the destination path and delete
// the source directory.
if (!$this->copyFolder($originalPath, $newPath)) {
return false;
}
$this->deleteFolder($originalPath);
}
else {
// If there's a risk that the directory name was updated
// by changing the letter case - swap source and destination
// using a temporary directory with random name.
$tempraryDirPath = $this->generateRandomTmpFolderName(dirname($originalPath));
if (!$this->copyFolder($originalPath, $tempraryDirPath)) {
$this->deleteFolder($tempraryDirPath);
return false;
}
$this->deleteFolder($originalPath);
return $this->moveFolder($tempraryDirPath, $newPath);
}
return true;
}
/**
* Creates a folder.
* @param string $path Specifies the folder path.
* @return boolean
*/
public function makeFolder($path)
{
$path = self::validatePath($path);
$fullPath = $this->getMediaPath($path);
return $this->getStorageDisk()->makeDirectory($fullPath);
}
/**
* Resets the Library cache.
*
* The cache stores the library table of contents locally in order to optimize
* the performance when working with remote storages. The default cache TTL is
* 10 minutes. The cache is deleted automatically when an item is added, changed
* or deleted. This method allows to reset the cache forcibly.
*/
public function resetCache()
{
Cache::forget($this->cacheKey);
}
/**
* Checks if file path doesn't contain any substrings that would pose a security threat.
* Throws an exception if the path is not valid.
* @param string $path Specifies the path.
* @param boolean $normalizeOnly Specifies if only the normalization, without validation should be performed.
* @return string Returns a normalized path.
*/
public static function validatePath($path, $normalizeOnly = false)
{
$path = str_replace('\\', '/', $path);
$path = '/'.trim($path, '/');
if ($normalizeOnly) {
return $path;
}
/*
* Validate folder names
*/
$regexWhitelist = [
'\w', // any word character
preg_quote('@', '/'),
preg_quote('.', '/'),
'\s', // whitespace character
preg_quote('-', '/'),
preg_quote('_', '/'),
preg_quote('/', '/'),
preg_quote('(', '/'),
preg_quote(')', '/'),
preg_quote('[', '/'),
preg_quote(']', '/'),
preg_quote(',', '/'),
preg_quote('=', '/'),
preg_quote("'", '/'),
preg_quote('&', '/'),
];
if (!preg_match('/^[' . implode('', $regexWhitelist) . ']+$/iu', $path)) {
throw new ApplicationException(Lang::get('system::lang.media.invalid_path', compact('path')));
}
$regexDirectorySeparator = preg_quote('/', '#');
$regexDot = preg_quote('.', '#');
$regex = [
// Beginning of path
'(^'.$regexDot.'+?'.$regexDirectorySeparator.')',
// Middle of path
'('.$regexDirectorySeparator.$regexDot.'+?'.$regexDirectorySeparator.')',
// End of path
'('.$regexDirectorySeparator.$regexDot.'+?$)',
];
/*
* Validate invalid paths
*/
$regex = '#'.implode('|', $regex).'#';
if (preg_match($regex, $path) !== 0 || strpos($path, '://') !== false) {
throw new ApplicationException(Lang::get('system::lang.media.invalid_path', compact('path')));
}
return $path;
}
/**
* Helper that makes a URL for a media file.
* @param string $file
* @return string
*/
public static function url($file)
{
return static::instance()->getPathUrl($file);
}
/**
* Returns a public file URL.
* @param string $path Specifies the file path relative the the Library root.
* @return string
*/
public function getPathUrl($path)
{
$path = $this->validatePath($path, true);
$fullPath = $this->storagePath . implode("/", array_map("rawurlencode", explode("/", $path)));
if (Config::get('cms.linkPolicy') === 'force') {
return Url::to($fullPath);
} else {
return $fullPath;
}
}
/**
* Returns a file or folder path with the prefixed storage folder.
* @param string $path Specifies a path to process.
* @return string Returns a processed string.
*/
public function getMediaPath($path)
{
return $this->storageFolder.$path;
}
/**
* Returns path relative to the Library root folder.
* @param string $path Specifies a path relative to the Library disk root.
* @return string Returns the updated path.
*/
protected function getMediaRelativePath($path)
{
$path = self::validatePath($path, true);
if (substr($path, 0, $this->storageFolderNameLength) == $this->storageFolder) {
return substr($path, $this->storageFolderNameLength);
}
throw new SystemException(sprintf('Cannot convert Media Library path "%s" to a path relative to the Library root.', $path));
}
/**
* Determines if the path should be visible (not ignored).
* @param string $path Specifies a path to check.
* @return boolean Returns TRUE if the path is visible.
*/
protected function isVisible($path)
{
$baseName = basename($path);
if (in_array($baseName, $this->ignoreNames)) {
return false;
}
foreach ($this->ignorePatterns as $pattern) {
if (preg_match('/'.$pattern.'/', $baseName)) {
return false;
}
}
return true;
}
/**
* Initializes a library item from file metadata and item type.
* @param array $item Specifies the file metadata as returned by the storage adapter.
* @param string $itemType Specifies the item type.
* @return mixed Returns the MediaLibraryItem object or NULL if the item is not visible.
*/
protected function initLibraryItem($item, $itemType)
{
$relativePath = $this->getMediaRelativePath($item['path']);
if (!$this->isVisible($relativePath)) {
return;
}
/*
* S3 doesn't allow getting the last modified timestamp for folders,
* so this feature is disabled - folders timestamp is always NULL.
*/
if ($itemType === MediaLibraryItem::TYPE_FILE) {
$lastModified = $item['timestamp'] ?? $this->getStorageDisk()->lastModified($item['path']);
} else {
$lastModified = null;
}
/*
* The folder size (number of items) doesn't respect filters. That
* could be confusing for users, but that's safer than displaying
* zero items for a folder that contains files not visible with a
* currently applied filter. -ab
*/
if ($itemType === MediaLibraryItem::TYPE_FILE) {
$size = $item['size'] ?? $this->getStorageDisk()->size($item['path']);
} else {
$size = $this->getFolderItemCount($item['path']);
}
$publicUrl = $this->getPathUrl($relativePath);
return new MediaLibraryItem($relativePath, $size, $lastModified, $itemType, $publicUrl);
}
/**
* Returns a number of items on a folder.
* @param string $path Specifies the folder path relative to the storage disk root.
* @return integer Returns the number of items in the folder.
*/
protected function getFolderItemCount($path)
{
$folderItems = array_merge(
$this->getStorageDisk()->files($path),
$this->getStorageDisk()->directories($path)
);
$size = 0;
foreach ($folderItems as $folderItem) {
if ($this->isVisible($folderItem)) {
$size++;
}
}
return $size;
}
/**
* Fetches the contents of a folder from the Library.
* @param string $fullFolderPath Specifies the folder path relative the the storage disk root.
* @return array Returns an array containing two elements - 'files' and 'folders', each is an array of MediaLibraryItem objects.
*/
protected function scanFolderContents($fullFolderPath)
{
$result = [
'files' => [],
'folders' => []
];
$contents = $this->getStorageDisk()->listContents($fullFolderPath);
foreach ($contents as $content) {
if ($content['type'] === 'file') {
$type = MediaLibraryItem::TYPE_FILE;
$key = 'files';
} elseif ($content['type'] === 'dir') {
$type = MediaLibraryItem::TYPE_FOLDER;
$key = 'folders';
}
$libraryItem = $this->initLibraryItem($content, $type);
if (!is_null($libraryItem)) {
$result[$key][] = $libraryItem;
}
}
return $result;
}
/**
* Sorts the item list by title, size or last modified date.
* @param array $itemList Specifies the item list to sort.
* @param mixed $sortSettings Determines the sorting preference.
* Supported values are 'title', 'size', 'lastModified' (see SORT_BY_XXX class constants) or an associative array with a 'by' key and a 'direction' key: ['by' => SORT_BY_XXX, 'direction' => SORT_DIRECTION_XXX].
*/
protected function sortItemList(&$itemList, $sortSettings)
{
$files = [];
$folders = [];
// Convert string $sortBy to array
if (is_string($sortSettings)) {
$sortSettings = [
'by' => $sortSettings,
'direction' => self::SORT_DIRECTION_ASC,
];
}
usort($itemList, function ($a, $b) use ($sortSettings) {
$result = 0;
switch ($sortSettings['by']) {
case self::SORT_BY_TITLE:
$result = strcasecmp($a->path, $b->path);
break;
case self::SORT_BY_SIZE:
if ($a->size < $b->size) {
$result = -1;
} else {
$result = $a->size > $b->size ? 1 : 0;
}
break;
case self::SORT_BY_MODIFIED:
if ($a->lastModified < $b->lastModified) {
$result = -1;
} else {
$result = $a->lastModified > $b->lastModified ? 1 : 0;
}
break;
}
// Reverse the polarity of the result to direct sorting in a descending order instead
if ($sortSettings['direction'] === self::SORT_DIRECTION_DESC) {
$result = 0 - $result;
}
return $result;
});
}
/**
* Filters item list by file type.
* @param array $itemList Specifies the item list to sort.
* @param string $filter Determines the document type filtering preference.
* Supported values are 'image', 'video', 'audio', 'document' (see FILE_TYPE_XXX constants of MediaLibraryItem class).
*/
protected function filterItemList(&$itemList, $filter)
{
if (!$filter) {
return;
}
$result = [];
foreach ($itemList as $item) {
if ($item->getFileType() == $filter) {
$result[] = $item;
}
}
$itemList = $result;
}
/**
* Initializes and returns the Media Library disk.
* This method should always be used instead of trying to access the
* $storageDisk property directly as initializing the disc requires
* communicating with the remote storage.
* @return mixed Returns the storage disk object.
*/
public function getStorageDisk(): FilesystemAdapter
{
if ($this->storageDisk) {
return $this->storageDisk;
}
return $this->storageDisk = Storage::disk(
Config::get('cms.storage.media.disk', 'local')
);
}
/**
* Determines if file path contains all words form the search term.
* @param string $path Specifies a path to examine.
* @param array $words A list of words to check against.
* @return boolean
*/
protected function pathMatchesSearch($path, $words)
{
$path = Str::lower($path);
foreach ($words as $word) {
$word = trim($word);
if (!strlen($word)) {
continue;
}
if (!Str::contains($path, $word)) {
return false;
}
}
return true;
}
protected function generateRandomTmpFolderName($location)
{
$temporaryDirBaseName = time();
$tmpPath = $location.'/tmp-'.$temporaryDirBaseName;
while ($this->folderExists($tmpPath)) {
$temporaryDirBaseName++;
$tmpPath = $location.'/tmp-'.$temporaryDirBaseName;
}
return $tmpPath;
}
}

View File

@@ -0,0 +1,155 @@
<?php namespace System\Classes;
use File;
use Config;
use Winter\Storm\Filesystem\Definitions as FileDefinitions;
use Carbon\Carbon;
/**
* Represents a file or folder in the Media Library.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class MediaLibraryItem
{
const TYPE_FILE = 'file';
const TYPE_FOLDER = 'folder';
const FILE_TYPE_IMAGE = 'image';
const FILE_TYPE_VIDEO = 'video';
const FILE_TYPE_AUDIO = 'audio';
const FILE_TYPE_DOCUMENT = 'document';
/**
* @var string Specifies the item path relative to the Library root.
*/
public $path;
/**
* @var integer Specifies the item size.
* For files the item size is measured in bytes. For folders it
* contains the number of files in the folder.
*/
public $size;
/**
* @var integer Contains the last modification time (Unix timestamp).
*/
public $lastModified;
/**
* @var string Specifies the item type.
*/
public $type;
/**
* @var string Specifies the public URL of the item.
*/
public $publicUrl;
/**
* @var array Contains a default list of image files and directories to ignore.
* Override with config: cms.storage.media.imageExtensions
*/
protected static $imageExtensions;
/**
* @var array Contains a default list of video files and directories to ignore.
* Override with config: cms.storage.media.videoExtensions
*/
protected static $videoExtensions;
/**
* @var array Contains a default list of audio files and directories to ignore.
* Override with config: cms.storage.media.audioExtensions
*/
protected static $audioExtensions;
/**
* @param string $path
* @param int $size
* @param int $lastModified
* @param string $type
* @param string $publicUrl
*/
public function __construct($path, $size, $lastModified, $type, $publicUrl)
{
$this->path = $path;
$this->size = $size;
$this->lastModified = $lastModified;
$this->type = $type;
$this->publicUrl = $publicUrl;
}
/**
* @return bool
*/
public function isFile()
{
return $this->type == self::TYPE_FILE;
}
/**
* Returns the file type by its name.
* The known file types are: image, video, audio, document
* @return string Returns the file type or NULL if the item is a folder.
*/
public function getFileType()
{
if (!$this->isFile()) {
return null;
}
if (!self::$imageExtensions) {
self::$imageExtensions = array_map('strtolower', Config::get('cms.storage.media.imageExtensions', FileDefinitions::get('imageExtensions')));
self::$videoExtensions = array_map('strtolower', Config::get('cms.storage.media.videoExtensions', FileDefinitions::get('videoExtensions')));
self::$audioExtensions = array_map('strtolower', Config::get('cms.storage.media.audioExtensions', FileDefinitions::get('audioExtensions')));
}
$extension = strtolower(pathinfo($this->path, PATHINFO_EXTENSION));
if (!strlen($extension)) {
return self::FILE_TYPE_DOCUMENT;
}
if (in_array($extension, self::$imageExtensions)) {
return self::FILE_TYPE_IMAGE;
}
if (in_array($extension, self::$videoExtensions)) {
return self::FILE_TYPE_VIDEO;
}
if (in_array($extension, self::$audioExtensions)) {
return self::FILE_TYPE_AUDIO;
}
return self::FILE_TYPE_DOCUMENT;
}
/**
* Returns the item size as string.
* For file-type items the size is the number of bytes. For folder-type items
* the size is the number of items contained by the item.
* @return string Returns the size as string.
*/
public function sizeToString()
{
return $this->type == self::TYPE_FILE
? File::sizeToString($this->size)
: $this->size . ' ' . trans_choice('system::lang.media.folder_size_items', $this->size);
}
/**
* Returns the item last modification date as string.
* @return string Returns the item's last modification date as string.
*/
public function lastModifiedAsString()
{
if (!($date = $this->lastModified)) {
return null;
}
return Carbon::createFromTimestamp($date)->toFormattedDateString();
}
}

View File

@@ -0,0 +1,41 @@
<?php namespace System\Classes;
use Lang;
use ApplicationException;
use Winter\Storm\Database\ModelBehavior as ModelBehaviorBase;
/**
* Base class for model behaviors.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class ModelBehavior extends ModelBehaviorBase
{
/**
* @var array Properties that must exist in the model using this behavior.
*/
protected $requiredProperties = [];
/**
* Constructor
* @param Winter\Storm\Database\Model $model The extended model.
*/
public function __construct($model)
{
parent::__construct($model);
/*
* Validate model properties
*/
foreach ($this->requiredProperties as $property) {
if (!isset($model->{$property})) {
throw new ApplicationException(Lang::get('system::lang.behavior.missing_property', [
'class' => get_class($model),
'property' => $property,
'behavior' => get_called_class()
]));
}
}
}
}

View File

@@ -0,0 +1,500 @@
<?php namespace System\Classes;
use Str;
use File;
use Yaml;
use Backend;
use ReflectionClass;
use SystemException;
use Composer\Semver\Semver;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\ServiceProvider as ServiceProviderBase;
/**
* Plugin base class
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class PluginBase extends ServiceProviderBase
{
/**
* @var \Winter\Storm\Foundation\Application The application instance.
*/
protected $app;
/**
* @var boolean
*/
protected $loadedYamlConfiguration = false;
/**
* @var string The absolute path to this plugin's directory, access with getPluginPath()
*/
protected $path;
/**
* @var string The version of this plugin as reported by updates/version.yaml, access with getPluginVersion()
*/
protected $version;
/**
* @var array Plugin dependencies
*/
public $require = [];
/**
* @var boolean Determine if this plugin should have elevated privileges.
*/
public $elevated = false;
/**
* @var boolean Determine if this plugin should be loaded (false) or not (true).
*/
public $disabled = false;
/**
* Returns information about this plugin, including plugin name and developer name.
*
* @return array
* @throws SystemException
*/
public function pluginDetails()
{
$thisClass = get_class($this);
$configuration = $this->getConfigurationFromYaml(sprintf('Plugin configuration file plugin.yaml is not '.
'found for the plugin class %s. Create the file or override pluginDetails() '.
'method in the plugin class.', $thisClass));
if (!array_key_exists('plugin', $configuration)) {
throw new SystemException(sprintf(
'The plugin configuration file plugin.yaml should contain the "plugin" section: %s.',
$thisClass
));
}
return $configuration['plugin'];
}
/**
* Register method, called when the plugin is first registered.
*
* @return void
*/
public function register()
{
}
/**
* Boot method, called right before the request route.
*
* @return void
*/
public function boot()
{
}
/**
* Registers CMS markup tags introduced by this plugin.
*
* @return array
*/
public function registerMarkupTags()
{
return [];
}
/**
* Registers any front-end components implemented in this plugin.
*
* @return array
*/
public function registerComponents()
{
return [];
}
/**
* Registers back-end navigation items for this plugin.
*
* @return array
*/
public function registerNavigation()
{
$configuration = $this->getConfigurationFromYaml();
if (array_key_exists('navigation', $configuration)) {
$navigation = $configuration['navigation'];
if (is_array($navigation)) {
array_walk_recursive($navigation, function (&$item, $key) {
if ($key === 'url') {
$item = Backend::url($item);
}
});
}
return $navigation;
}
}
/**
* Registers back-end quick actions for this plugin.
*
* @return array
*/
public function registerQuickActions()
{
$configuration = $this->getConfigurationFromYaml();
if (array_key_exists('quickActions', $configuration)) {
$quickActions = $configuration['quickActions'];
if (is_array($quickActions)) {
array_walk_recursive($quickActions, function (&$item, $key) {
if ($key === 'url') {
$item = Backend::url($item);
}
});
}
return $quickActions;
}
}
/**
* Registers any back-end permissions used by this plugin.
*
* @return array
*/
public function registerPermissions()
{
$configuration = $this->getConfigurationFromYaml();
if (array_key_exists('permissions', $configuration)) {
return $configuration['permissions'];
}
}
/**
* Registers any back-end configuration links used by this plugin.
*
* @return array
*/
public function registerSettings()
{
$configuration = $this->getConfigurationFromYaml();
if (array_key_exists('settings', $configuration)) {
return $configuration['settings'];
}
}
/**
* Registers scheduled tasks that are executed on a regular basis.
*
* @param Schedule $schedule
* @return void
*/
public function registerSchedule($schedule)
{
}
/**
* Registers any report widgets provided by this plugin.
* The widgets must be returned in the following format:
*
* return [
* 'className1'=>[
* 'label' => 'My widget 1',
* 'context' => ['context-1', 'context-2'],
* ],
* 'className2' => [
* 'label' => 'My widget 2',
* 'context' => 'context-1'
* ]
* ];
*
* @return array
*/
public function registerReportWidgets()
{
return [];
}
/**
* Registers any form widgets implemented in this plugin.
* The widgets must be returned in the following format:
*
* return [
* ['className1' => 'alias'],
* ['className2' => 'anotherAlias']
* ];
*
* @return array
*/
public function registerFormWidgets()
{
return [];
}
/**
* Registers custom back-end list column types introduced by this plugin.
*
* @return array
*/
public function registerListColumnTypes()
{
return [];
}
/**
* Registers any mail layouts implemented by this plugin.
* The layouts must be returned in the following format:
*
* return [
* 'marketing' => 'acme.blog::layouts.marketing',
* 'notification' => 'acme.blog::layouts.notification',
* ];
*
* @return array
*/
public function registerMailLayouts()
{
return [];
}
/**
* Registers any mail templates implemented by this plugin.
* The templates must be returned in the following format:
*
* return [
* 'acme.blog::mail.welcome',
* 'acme.blog::mail.forgot_password',
* ];
*
* @return array
*/
public function registerMailTemplates()
{
return [];
}
/**
* Registers any mail partials implemented by this plugin.
* The partials must be returned in the following format:
*
* return [
* 'tracking' => 'acme.blog::partials.tracking',
* 'promotion' => 'acme.blog::partials.promotion',
* ];
*
* @return array
*/
public function registerMailPartials()
{
return [];
}
/**
* Registers a new console (artisan) command
*
* @param string $key The command name
* @param string|\Closure $command The command class or closure
* @return void
*/
public function registerConsoleCommand($key, $command)
{
$key = 'command.'.$key;
$this->app->singleton($key, $command);
$this->commands($key);
}
/**
* Read configuration from YAML file
*
* @param string|null $exceptionMessage
* @return array|bool
* @throws SystemException
*/
protected function getConfigurationFromYaml($exceptionMessage = null)
{
if ($this->loadedYamlConfiguration !== false) {
return $this->loadedYamlConfiguration;
}
$reflection = new ReflectionClass(get_class($this));
$yamlFilePath = dirname($reflection->getFileName()).'/plugin.yaml';
if (!file_exists($yamlFilePath)) {
if ($exceptionMessage) {
throw new SystemException($exceptionMessage);
}
$this->loadedYamlConfiguration = [];
}
else {
$this->loadedYamlConfiguration = Yaml::parseFile($yamlFilePath);
if (!is_array($this->loadedYamlConfiguration)) {
throw new SystemException(sprintf('Invalid format of the plugin configuration file: %s. The file should define an array.', $yamlFilePath));
}
}
return $this->loadedYamlConfiguration;
}
/**
* Gets list of plugins replaced by this plugin
*
* @param bool $includeConstraints Include version constraints in the results as the array values
* @return array ['Author.Plugin'] or ['Author.Plugin' => 'self.version']
*/
public function getReplaces($includeConstraints = false): array
{
$replaces = $this->pluginDetails()['replaces'] ?? null;
if ($includeConstraints) {
if (is_string($replaces)) {
$replaces = [$replaces => 'self.version'];
}
} else {
if (is_array($replaces)) {
$replaces = array_keys($replaces);
} elseif (is_string($replaces)) {
$replaces = [$replaces];
}
}
return is_array($replaces) ? $replaces : [];
}
/**
* Check if the provided plugin & version can be replaced by this plugin
*
* @param string $pluginIdentifier
* @param string $version
* @return bool
*/
public function canReplacePlugin(string $pluginIdentifier, string $version): bool
{
$replaces = $this->getReplaces(true);
if (is_array($replaces) && in_array($pluginIdentifier, array_keys($replaces))) {
$constraints = $replaces[$pluginIdentifier];
if ($constraints === 'self.version') {
$constraints = $this->getPluginVersion();
}
return Semver::satisfies($version, $constraints);
}
return false;
}
/**
* Gets the identifier for this plugin
*
* @return string Identifier in format of Author.Plugin
*/
public function getPluginIdentifier(): string
{
$namespace = Str::normalizeClassName(get_class($this));
if (strpos($namespace, '\\') === null) {
return $namespace;
}
$parts = explode('\\', $namespace);
$slice = array_slice($parts, 1, 2);
$namespace = implode('.', $slice);
return $namespace;
}
/**
* Returns the absolute path to this plugin's directory
*/
public function getPluginPath(): string
{
if ($this->path) {
return $this->path;
}
$reflection = new ReflectionClass($this);
$this->path = File::normalizePath(dirname($reflection->getFileName()));
return $this->path;
}
/**
* Gets the current version of the plugin as reported by updates/version.yaml
*/
public function getPluginVersion(): string
{
if (isset($this->version)) {
return $this->version;
}
$versions = $this->getPluginVersions();
if (empty($versions)) {
return $this->version = (string) VersionManager::NO_VERSION_VALUE;
}
return $this->version = trim(key(array_slice($versions, -1, 1)));
}
/**
* Gets the contents of the plugin's updates/version.yaml file and normalizes the results
*/
public function getPluginVersions(bool $includeScripts = true): array
{
$path = $this->getPluginPath();
$versionFile = $path . '/updates/version.yaml';
if (!File::isFile($versionFile)) {
return [];
}
$updates = Yaml::withProcessor(new VersionYamlProcessor, function ($yaml) use ($versionFile) {
return (array) $yaml->parseFile($versionFile);
});
uksort($updates, function ($a, $b) {
return version_compare($a, $b);
});
$versions = [];
foreach ($updates as $version => $details) {
if (!is_array($details)) {
$details = [$details];
}
if (!$includeScripts) {
// Filter out valid update scripts
$details = array_values(array_filter($details, function ($string) use ($path) {
return !Str::endsWith($string, '.php') || !File::exists($path . '/updates/' . $string);
}));
}
$versions[$version] = $details;
}
return $versions;
}
/**
* Verifies the plugin's dependencies are present and enabled
*/
public function checkDependencies(PluginManager $manager): bool
{
$required = $manager->getDependencies($this);
if (empty($required)) {
return true;
}
foreach ($required as $require) {
$requiredPlugin = $manager->findByIdentifier($require);
if (!$requiredPlugin || $manager->isDisabled($requiredPlugin)) {
return false;
}
}
return true;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,417 @@
<?php namespace System\Classes;
use Event;
use Backend;
use BackendAuth;
use SystemException;
/**
* Manages the system settings.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class SettingsManager
{
use \Winter\Storm\Support\Traits\Singleton;
use \System\Traits\LazyOwnerAlias;
/**
* Allocated category types
*/
const CATEGORY_CMS = 'system::lang.system.categories.cms';
const CATEGORY_MISC = 'system::lang.system.categories.misc';
const CATEGORY_MAIL = 'system::lang.system.categories.mail';
const CATEGORY_LOGS = 'system::lang.system.categories.logs';
const CATEGORY_SHOP = 'system::lang.system.categories.shop';
const CATEGORY_TEAM = 'system::lang.system.categories.team';
const CATEGORY_USERS = 'system::lang.system.categories.users';
const CATEGORY_SOCIAL = 'system::lang.system.categories.social';
const CATEGORY_SYSTEM = 'system::lang.system.categories.system';
const CATEGORY_EVENTS = 'system::lang.system.categories.events';
const CATEGORY_BACKEND = 'system::lang.system.categories.backend';
const CATEGORY_CUSTOMERS = 'system::lang.system.categories.customers';
const CATEGORY_MYSETTINGS = 'system::lang.system.categories.my_settings';
const CATEGORY_NOTIFICATIONS = 'system::lang.system.categories.notifications';
/**
* @var array Cache of registration callbacks.
*/
protected $callbacks = [];
/**
* @var array List of registered items.
*/
protected $items;
/**
* @var array List of owner aliases. ['Aliased.Owner' => 'Real.Owner']
*/
protected $aliases = [];
/**
* @var array Grouped collection of all items, by category.
*/
protected $groupedItems;
/**
* @var string Active plugin or module owner.
*/
protected $contextOwner;
/**
* @var string Active item code.
*/
protected $contextItemCode;
/**
* @var array Settings item defaults.
*/
protected static $itemDefaults = [
'code' => null,
'label' => null,
'category' => null,
'icon' => null,
'url' => null,
'permissions' => [],
'order' => 500,
'context' => 'system',
'keywords' => null
];
/**
* @var System\Classes\PluginManager
*/
protected $pluginManager;
/**
* Initialize this singleton.
*/
protected function init()
{
foreach (static::$lazyAliases as $alias => $owner) {
$this->registerOwnerAlias($owner, $alias);
}
$this->pluginManager = PluginManager::instance();
}
protected function loadItems()
{
/*
* Load module items
*/
foreach ($this->callbacks as $callback) {
$callback($this);
}
/*
* Load plugin items
*/
$plugins = $this->pluginManager->getPlugins();
foreach ($plugins as $id => $plugin) {
$items = $plugin->registerSettings();
if (!is_array($items)) {
continue;
}
$this->registerSettingItems($id, $items);
}
/**
* @event system.settings.extendItems
* Provides an opportunity to manipulate the system settings manager
*
* Example usage:
*
* Event::listen('system.settings.extendItems', function ((\System\Classes\SettingsManager) $settingsManager) {
* $settingsManager->addSettingItem(...)
* $settingsManager->removeSettingItem(...)
* });
*
*/
Event::fire('system.settings.extendItems', [$this]);
/*
* Sort settings items
*/
uasort($this->items, function ($a, $b) {
return $a->order - $b->order;
});
/*
* Filter items user lacks permission for
*/
$user = BackendAuth::getUser();
$this->items = $this->filterItemPermissions($user, $this->items);
/*
* Process each item in to a category array
*/
$catItems = [];
foreach ($this->items as $code => $item) {
$category = $item->category ?: self::CATEGORY_MISC;
if (!isset($catItems[$category])) {
$catItems[$category] = [];
}
$catItems[$category][$code] = $item;
}
$this->groupedItems = $catItems;
}
/**
* Returns a collection of all settings by group, filtered by context
* @param string $context
* @return array
*/
public function listItems($context = null)
{
if ($this->items === null) {
$this->loadItems();
}
if ($context !== null) {
return $this->filterByContext($this->groupedItems, $context);
}
return $this->groupedItems;
}
/**
* Filters a set of items by a given context.
* @param array $items
* @param string $context
* @return array
*/
protected function filterByContext($items, $context)
{
$filteredItems = [];
foreach ($items as $categoryName => $category) {
$filteredCategory = [];
foreach ($category as $item) {
$itemContext = is_array($item->context) ? $item->context : [$item->context];
if (in_array($context, $itemContext)) {
$filteredCategory[] = $item;
}
}
if (count($filteredCategory)) {
$filteredItems[$categoryName] = $filteredCategory;
}
}
return $filteredItems;
}
/**
* Registers a callback function that defines setting items.
* The callback function should register setting items by calling the manager's
* registerSettingItems() function. The manager instance is passed to the
* callback function as an argument. Usage:
*
* SettingsManager::registerCallback(function ($manager) {
* $manager->registerSettingItems([...]);
* });
*
* @param callable $callback A callable function.
*/
public function registerCallback(callable $callback)
{
$this->callbacks[] = $callback;
}
/**
* Registers the back-end setting items.
* The argument is an array of the settings items. The array keys represent the
* setting item codes, specific for the plugin/module. Each element in the
* array should be an associative array with the following keys:
* - label - specifies the settings label localization string key, required.
* - icon - an icon name from the Font Awesome icon collection, required.
* - url - the back-end relative URL the setting item should point to.
* - class - the back-end relative URL the setting item should point to.
* - permissions - an array of permissions the back-end user should have, optional.
* The item will be displayed if the user has any of the specified permissions.
* - order - a position of the item in the setting, optional.
* - category - a string to assign this item to a category, optional.
* @param string $owner Specifies the setting items owner plugin or module in the format Vendor.Module.
* @param array $definitions An array of the setting item definitions.
*/
public function registerSettingItems($owner, array $definitions)
{
if (!$this->items) {
$this->items = [];
}
$this->addSettingItems($owner, $definitions);
}
/**
* Register an owner alias
*
* @param string $owner The owner to register an alias for. Example: Real.Owner
* @param string $alias The alias to register. Example: Aliased.Owner
* @return void
*/
public function registerOwnerAlias(string $owner, string $alias)
{
$this->aliases[strtolower($alias)] = $owner;
}
/**
* Dynamically add an array of setting items
* @param string $owner
* @param array $definitions
*/
public function addSettingItems($owner, array $definitions)
{
foreach ($definitions as $code => $definition) {
$this->addSettingItem($owner, $code, $definition);
}
}
/**
* Dynamically add a single setting item
* @param string $owner
* @param string $code
* @param array $definitions
*/
public function addSettingItem($owner, $code, array $definition)
{
$itemKey = $this->makeItemKey($owner, $code);
if (isset($this->items[$itemKey])) {
$definition = array_merge((array) $this->items[$itemKey], $definition);
}
$item = array_merge(self::$itemDefaults, array_merge($definition, [
'code' => $code,
'owner' => $owner
]));
/*
* Link to the generic settings page if a URL is not provided
*/
if (isset($item['class']) && !isset($item['url'])) {
$uri = [];
if (strpos($owner, '.') !== null) {
list($author, $plugin) = explode('.', $owner);
$uri[] = strtolower($author);
$uri[] = strtolower($plugin);
}
else {
$uri[] = strtolower($owner);
}
$uri[] = strtolower($code);
$uri = implode('/', $uri);
$item['url'] = Backend::url('system/settings/update/' . $uri);
}
$this->items[$itemKey] = (object) $item;
}
/**
* Removes a single setting item
*/
public function removeSettingItem($owner, $code)
{
if (!$this->items) {
throw new SystemException('Unable to remove settings item before items are loaded.');
}
$itemKey = $this->makeItemKey($owner, $code);
unset($this->items[$itemKey]);
if ($this->groupedItems) {
foreach ($this->groupedItems as $category => $items) {
if (isset($items[$itemKey])) {
unset($this->groupedItems[$category][$itemKey]);
}
}
}
}
/**
* Sets the navigation context.
* @param string $owner Specifies the setting items owner plugin or module in the format Vendor.Module.
* @param string $code Specifies the settings item code.
*/
public static function setContext($owner, $code)
{
$instance = self::instance();
$instance->contextOwner = strtolower($owner);
$instance->contextItemCode = strtolower($code);
}
/**
* Returns information about the current settings context.
* @return mixed Returns an object with the following fields:
* - itemCode
* - owner
*/
public function getContext()
{
return (object) [
'itemCode' => $this->contextItemCode,
'owner' => strtolower($this->aliases[$this->contextOwner] ?? $this->contextOwner),
];
}
/**
* Locates a setting item object by it's owner and code
* @param string $owner
* @param string $code
* @return mixed The item object or FALSE if nothing is found
*/
public function findSettingItem($owner, $code)
{
if ($this->items === null) {
$this->loadItems();
}
$itemKey = $this->makeItemKey($owner, $code);
if (isset($this->items[$itemKey])) {
return $this->items[$itemKey];
}
return false;
}
/**
* Removes settings items from an array if the supplied user lacks permission.
* @param User $user A user object
* @param array $items A collection of setting items
* @return array The filtered settings items
*/
protected function filterItemPermissions($user, array $items)
{
if (!$user) {
return $items;
}
$items = array_filter($items, function ($item) use ($user) {
if (!$item->permissions || !count($item->permissions)) {
return true;
}
return $user->hasAnyAccess($item->permissions);
});
return $items;
}
/**
* Internal method to make a unique key for an item.
* @param object $item
* @return string
*/
protected function makeItemKey($owner, $code)
{
return strtoupper($this->aliases[strtolower($owner)] ?? $owner).'.'.strtoupper($code);
}
}

View File

@@ -0,0 +1,491 @@
<?php namespace System\Classes;
use Http;
use Config;
use ApplicationException;
use Winter\Storm\Argon\Argon;
/**
* Reads and stores the Winter CMS source manifest information.
*
* The source manifest is a meta JSON file, stored on GitHub, that contains the hashsums of all module files across all
* builds of Winter CMS. This allows us to compare the Winter CMS installation against the expected file checksums and
* determine the installed build and whether it has been modified.
*
* Since Winter CMS v1.1.1, a forks manifest is also used to determine at which point we forked a branch off to a new
* major release. This allows us to track concurrent histories - ie. the 1.0.x history vs. the 1.1.x history.
*
* @package winter\wn-system-module
* @author Ben Thomson
*/
class SourceManifest
{
/**
* @var string The URL to the source manifest
*/
protected $source;
/**
* @var array Array of builds, keyed by build number, with files for keys and hashes for values.
*/
protected $builds = [];
/**
* @var array The version map where forks occurred.
*/
protected $forks;
/**
* @var string The URL to the forked version manifest
*/
protected $forksUrl;
/**
* Constructor
*/
public function __construct(?string $source = null, ?string $forks = null, bool $autoload = true)
{
$this->setSource($source ?? Config::get(
'cms.sourceManifestUrl',
'https://raw.githubusercontent.com/wintercms/meta/master/manifest/builds.json'
));
$this->setForksSource($forks ?? Config::get(
'cms.forkManifestUrl',
'https://raw.githubusercontent.com/wintercms/meta/master/manifest/forks.json'
));
if ($autoload) {
$this->loadSource();
$this->loadForks();
}
}
/**
* Sets the source manifest URL.
*/
public function setSource(string $source): void
{
$this->source = $source;
}
/**
* Sets the forked version manifest URL.
*/
public function setForksSource(string $forks): void
{
$this->forksUrl = $forks;
}
/**
* Loads the manifest file.
*
* @throws ApplicationException If the manifest is invalid, or cannot be parsed.
*/
public function loadSource(): static
{
if (file_exists($this->source)) {
$source = file_get_contents($this->source);
} else {
$source = Http::get($this->source)->body;
}
if (empty($source)) {
throw new ApplicationException(
'Source manifest not found'
);
}
$data = json_decode($source, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new ApplicationException(
'Unable to decode source manifest JSON data. JSON Error: ' . json_last_error_msg()
);
}
if (!isset($data['manifest']) || !is_array($data['manifest'])) {
throw new ApplicationException(
'The source manifest at "' . $this->source . '" does not appear to be a valid source manifest file.'
);
}
foreach ($data['manifest'] as $build) {
$this->builds[$this->getVersionInt($build['build'])] = [
'version' => $build['build'],
'parent' => $build['parent'],
'modules' => $build['modules'],
'files' => $build['files'],
];
}
return $this;
}
/**
* Loads the forked version manifest file.
*
* @throws ApplicationException If the manifest is invalid, or cannot be parsed.
*/
public function loadForks(): static
{
if (file_exists($this->forksUrl)) {
$forks = file_get_contents($this->forksUrl);
} else {
$forks = Http::get($this->forksUrl)->body;
}
if (empty($forks)) {
throw new ApplicationException(
'Forked version manifest not found'
);
}
$data = json_decode($forks, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new ApplicationException(
'Unable to decode forked version manifest JSON data. JSON Error: ' . json_last_error_msg()
);
}
if (!isset($data['forks']) || !is_array($data['forks'])) {
throw new ApplicationException(
'The forked version manifest at "' . $this->forksUrl . '" does not appear to be a valid forked version
manifest file.'
);
}
// Map forks to int values
foreach ($data['forks'] as $child => $parent) {
$this->forks[$this->getVersionInt($child)] = $this->getVersionInt($parent);
}
return $this;
}
/**
* Adds a FileManifest instance as a build to this source manifest.
*
* Changes between builds are calculated and stored with the build. Builds are stored in order of semantic
* versioning: ie. 1.1.1 > 1.1.0 > 1.0.468
*
* @param integer $build Build number.
* @param FileManifest $manifest The file manifest to add as a build.
*/
public function addBuild($build, FileManifest $manifest): void
{
$parent = $this->determineParent($build);
if (!is_null($parent)) {
$parent = $parent['version'];
}
$this->builds[$this->getVersionInt($build)] = [
'version' => $build,
'modules' => $manifest->getModuleChecksums(),
'parent' => $parent,
'files' => $this->processChanges($manifest, $parent),
];
// Sort builds numerically in ascending order.
ksort($this->builds, SORT_NUMERIC);
}
/**
* Gets all builds.
*/
public function getBuilds(): array
{
return array_values(array_map(function ($build) {
return $build['version'];
}, $this->builds));
}
/**
* Generates the JSON data to be stored with the source manifest.
*
* @throws ApplicationException If no builds have been added to this source manifest.
*/
public function generate(): string
{
if (!count($this->builds)) {
throw new ApplicationException(
'No builds have been added to the manifest.'
);
}
$json = [
'_description' => 'This is the source manifest of changes to Winter CMS for each version. This is used to'
. ' determine which version of Winter CMS is in use, via the "winter:version" Artisan command.',
'_created' => Argon::now()->toIso8601String(),
'manifest' => [],
];
foreach (array_values($this->builds) as $details) {
$json['manifest'][] = [
'build' => $details['version'],
'parent' => $details['parent'] ?? null,
'modules' => $details['modules'],
'files' => $details['files'],
];
}
return json_encode($json, JSON_PRETTY_PRINT);
}
/**
* Gets the filelist state at a selected build.
*
* This method will list all expected files and hashsums at the specified build number. It does this by following
* the history, switching branches as necessary.
*
* @param string|integer $build Build version to get the filelist state for.
* @throws ApplicationException If the specified build has not been added to the source manifest.
*/
public function getState(mixed $build): array
{
if (is_string($build)) {
$build = $this->getVersionInt($build);
}
if (!isset($this->builds[$build])) {
throw new \Exception('The specified build has not been added.');
}
$state = [];
foreach ($this->builds as $number => $details) {
// Follow fork if necessary
if (isset($this->forks) && array_key_exists($build, $this->forks)) {
$state = $this->getState($this->forks[$build]);
}
if (isset($details['files']['added'])) {
foreach ($details['files']['added'] as $filename => $sum) {
$state[$filename] = $sum;
}
}
if (isset($details['files']['modified'])) {
foreach ($details['files']['modified'] as $filename => $sum) {
$state[$filename] = $sum;
}
}
if (isset($details['files']['removed'])) {
foreach ($details['files']['removed'] as $filename) {
unset($state[$filename]);
}
}
if ($number === $build) {
break;
}
}
return $state;
}
/**
* Compares a file manifest with the source manifest.
*
* This will determine the build of the Winter CMS installation.
*
* This will return an array with the following information:
* - `build`: The build number we determined was most likely the build installed.
* - `modified`: Whether we detected any modifications between the installed build and the manifest.
* - `confident`: Whether we are at least 60% sure that this is the installed build. More modifications to
* to the code = less confidence.
* - `changes`: If $detailed is true, this will include the list of files modified, created and deleted.
*
* @param FileManifest $manifest The file manifest to compare against the source.
* @param bool $detailed If true, the list of files modified, added and deleted will be included in the result.
*/
public function compare(FileManifest $manifest, bool $detailed = false): array
{
$modules = $manifest->getModuleChecksums();
// Look for an unmodified version
foreach ($this->getBuilds() as $buildString) {
$build = $this->builds[$this->getVersionInt($buildString)];
$matched = array_intersect_assoc($build['modules'], $modules);
if (count($matched) === count($build['modules'])) {
$details = [
'build' => $buildString,
'modified' => false,
'confident' => true,
];
if ($detailed) {
$details['changes'] = [];
}
return $details;
}
}
// If we could not find an unmodified version, try to find the closest version and assume this is a modified
// install.
$buildMatch = [];
foreach ($this->getBuilds() as $buildString) {
$build = $this->builds[$this->getVersionInt($buildString)];
$state = $this->getState($buildString);
// Include only the files that match the modules being loaded in this file manifest
$availableModules = array_keys($modules);
foreach ($state as $file => $sum) {
// Determine module
$module = explode('/', $file)[2];
if (!in_array($module, $availableModules)) {
unset($state[$file]);
}
}
$filesExpected = count($state);
$filesFound = [];
$filesChanged = [];
foreach ($manifest->getFiles() as $file => $sum) {
// Unknown new file
if (!isset($state[$file])) {
$filesChanged[] = $file;
continue;
}
// Modified file
if ($state[$file] !== $sum) {
$filesFound[] = $file;
$filesChanged[] = $file;
continue;
}
// Pristine file
$filesFound[] = $file;
}
$foundPercent = count($filesFound) / $filesExpected;
$changedPercent = count($filesChanged) / $filesExpected;
$score = ((1 * $foundPercent) - $changedPercent);
$buildMatch[$buildString] = round($score * 100, 2);
}
// Find likely version
$likelyBuild = array_search(max($buildMatch), $buildMatch);
$details = [
'build' => $likelyBuild,
'modified' => true,
'confident' => ($buildMatch[$likelyBuild] >= 60)
];
if ($detailed) {
$details['changes'] = $this->processChanges($manifest, $likelyBuild);
}
return $details;
}
/**
* Determines file changes between the specified build and the previous build.
*
* Will return an array of added, modified and removed files.
*
* @param FileManifest $manifest The current build's file manifest.
* @param FileManifest|string|integer $previous Either a previous manifest, or the previous build number as an int
* or string, used to determine changes with this build.
*/
protected function processChanges(FileManifest $manifest, mixed $previous = null): array
{
// If no previous build has been provided, all files are added
if (is_null($previous)) {
return [
'added' => $manifest->getFiles(),
];
}
// Only save files if they are changing the "state" of the manifest (ie. the file is modified, added or removed)
if (is_int($previous) || is_string($previous)) {
$state = $this->getState($previous);
} else {
$state = $previous->getFiles();
}
$added = [];
$modified = [];
foreach ($manifest->getFiles() as $file => $sum) {
if (!isset($state[$file])) {
$added[$file] = $sum;
continue;
} else {
if ($state[$file] !== $sum) {
$modified[$file] = $sum;
}
unset($state[$file]);
}
}
// Any files still left in state have been removed
$removed = array_keys($state);
$changes = [];
if (count($added)) {
$changes['added'] = $added;
}
if (count($modified)) {
$changes['modified'] = $modified;
}
if (count($removed)) {
$changes['removed'] = $removed;
}
return $changes;
}
/**
* Determine the parent of the provided build number
*/
protected function determineParent(string $build): ?array
{
$buildInt = $this->getVersionInt($build);
// First, we'll check for a fork - if so, the source version for the fork is a parent
if (isset($this->forks) && array_key_exists($buildInt, $this->forks)) {
return $this->builds[$this->forks[$buildInt]];
}
// If not a fork, then determine the parent by finding the nearest minor version to the build
$parent = null;
for ($i = 1; $i <= 999; ++$i) {
if (array_key_exists($buildInt - $i, $this->builds)) {
$parent = $this->builds[$buildInt - $i];
break;
}
}
return $parent;
}
/**
* Converts a version string into an integer for comparison.
*
* @throws ApplicationException if a version string does not match the format "major.minor.path"
*/
protected function getVersionInt(string $version): int
{
// Get major.minor.patch versions
if (!preg_match('/^([0-9]+)\.([0-9]+)\.([0-9]+)/', $version, $versionParts)) {
throw new ApplicationException('Invalid version string - must be of the format "major.minor.path"');
}
$int = $versionParts[1] * 1000000;
$int += $versionParts[2] * 1000;
$int += $versionParts[3];
return $int;
}
}

View File

@@ -0,0 +1,88 @@
<?php namespace System\Classes;
use Lang;
use Config;
use Response;
use Exception;
use SystemException;
use ApplicationException;
use Illuminate\Routing\Controller as ControllerBase;
/**
* The is the master controller for system related routing.
* It is currently only responsible for serving up the asset combiner contents.
*
* @see System\Classes\CombineAssets Asset combiner class
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges, Luke Towers
*/
class SystemController extends ControllerBase
{
/**
* Combines JavaScript and StyleSheet assets.
* @param string $name Combined file code
* @return Response Combined content.
*/
public function combine($name)
{
try {
if (!strpos($name, '-')) {
return Response::make('/* '.e(Lang::get('system::lang.combiner.not_found', ['name' => $name])).' */', 404);
}
$parts = explode('-', $name);
$cacheId = $parts[0];
$combiner = CombineAssets::instance();
return $combiner->getContents($cacheId);
} catch (Exception $ex) {
return Response::make('/* '.e($ex->getMessage()).' */', 500);
}
}
/**
* Resizes an image using the provided configuration
* and returns a redirect to the resized image
*
* @param string $identifier The identifier used to retrieve the image configuration
* @param string $encodedUrl The double-encoded URL of the resized image, see https://github.com/octobercms/october/issues/3592#issuecomment-671017380
* @return RedirectResponse
*/
public function resizer(string $identifier, string $encodedUrl)
{
$resizedUrl = ImageResizer::getValidResizedUrl($identifier, $encodedUrl);
if (empty($resizedUrl)) {
return response('Invalid identifier or redirect URL', 400);
}
// Attempt to process the resize
try {
$resizer = ImageResizer::fromIdentifier($identifier);
$resizer->resize();
} catch (SystemException $ex) {
// If the resizing failed with a SystemException, it was most
// likely because it is in progress or has already finished
// although it could also be because the cache system used to store
// configuration data is broken
if (Config::get('cache.default', 'file') === 'array') {
throw new Exception('Image resizing requires a persistent cache driver, "array" is not supported. Try changing config/cache.php -> default to a persistent cache driver.');
}
} catch (Exception $ex) {
// If it failed for any other reason, restore the config so that
// the resizer route will continue to work until it succeeds
if (!empty($resizer)) {
$resizer->storeConfig();
}
// Rethrow the exception
throw $ex;
}
// Redirect permanently as a resizer URL can only ever target the resized URL
// embedded and signed within it, and crawlers should index the resized URL
// rather than the temporary resizer URL
return redirect()->to($resizedUrl, 301);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,723 @@
<?php namespace System\Classes;
use File;
use Yaml;
use Db;
use Carbon\Carbon;
use Illuminate\Console\View\Components\Error;
use Illuminate\Console\View\Components\Info;
use Illuminate\Console\View\Components\Task;
use Winter\Storm\Database\Updater;
/**
* Version manager
*
* Manages the versions and database updates for plugins.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class VersionManager
{
use \Winter\Storm\Support\Traits\Singleton;
/**
* Value when no updates are found.
*/
const NO_VERSION_VALUE = 0;
/**
* Morph types for history table.
*/
const HISTORY_TYPE_COMMENT = 'comment';
const HISTORY_TYPE_SCRIPT = 'script';
/**
* @var \Illuminate\Console\OutputStyle
*/
protected $notesOutput;
/**
* Cache of plugin versions as files.
*/
protected $fileVersions;
/**
* Cache of database versions
*/
protected $databaseVersions;
/**
* Cache of database history
*/
protected $databaseHistory;
/**
* @var Winter\Storm\Database\Updater
*/
protected $updater;
/**
* @var System\Classes\PluginManager
*/
protected $pluginManager;
protected function init()
{
$this->updater = new Updater;
$this->pluginManager = PluginManager::instance();
}
/**
* Updates a single plugin by its code or object with it's latest changes.
* If the $stopAfterVersion parameter is specified, the process stops after
* the specified version is applied.
*/
public function updatePlugin($plugin, $stopAfterVersion = null)
{
$code = is_string($plugin) ? $plugin : $this->pluginManager->getIdentifier($plugin);
if (!$this->hasVersionFile($code)) {
return false;
}
$currentVersion = $this->getLatestFileVersion($code);
$databaseVersion = $this->getDatabaseVersion($code);
$this->out('', true);
// No updates needed
if ($currentVersion === (string) $databaseVersion) {
$this->write(Info::class, 'Nothing to migrate.');
return;
}
$newUpdates = $this->getNewFileVersions($code, $databaseVersion);
$this->write(Info::class, 'Running migrations.');
foreach ($newUpdates as $version => $details) {
$this->applyPluginUpdate($code, $version, $details);
if ($stopAfterVersion === $version) {
return true;
}
}
$this->out('', true);
return true;
}
/**
* Update the current replaced plugin's version to reference the replacing plugin.
*/
public function replacePlugin(PluginBase $plugin, string $replace)
{
$currentVersion = $this->getDatabaseVersion($replace);
if ($currentVersion === self::NO_VERSION_VALUE) {
return;
}
// We only care about the database version of the replaced plugin at this point
if (!$plugin->canReplacePlugin($replace, $currentVersion)) {
return;
}
$code = $plugin->getPluginIdentifier();
// Replace existing migration information with the new identifier
if ($versions = $this->getOldFileVersions($code, $currentVersion)) {
foreach ($versions as $version => $details) {
list($comments, $scripts) = $this->extractScriptsAndComments($details);
$now = now()->toDateTimeString();
foreach ($scripts as $script) {
Db::table('system_plugin_history')->insert([
'code' => $code,
'type' => self::HISTORY_TYPE_SCRIPT,
'version' => $version,
'detail' => $script,
'created_at' => $now,
]);
}
foreach ($comments as $comment) {
$this->applyDatabaseComment($code, $version, $comment);
}
}
// delete replaced plugin history
Db::table('system_plugin_history')->where('code', $replace)->delete();
// replace installed version
Db::table('system_plugin_versions')
->where('code', '=', $replace)
->update([
'code' => $code
]);
}
}
/**
* Returns a list of unapplied plugin versions.
*/
public function listNewVersions($plugin)
{
$code = is_string($plugin) ? $plugin : $this->pluginManager->getIdentifier($plugin);
if (!$this->hasVersionFile($code)) {
return [];
}
$databaseVersion = $this->getDatabaseVersion($code);
return $this->getNewFileVersions($code, $databaseVersion);
}
/**
* Applies a single version update to a plugin.
*/
protected function applyPluginUpdate($code, $version, $details)
{
list($comments, $scripts) = $this->extractScriptsAndComments($details);
$updateFn = function () use ($code, $version, $comments, $scripts) {
/*
* Apply scripts, if any
*/
foreach ($scripts as $script) {
if ($this->hasDatabaseHistory($code, $version, $script)) {
continue;
}
$this->applyDatabaseScript($code, $version, $script);
}
/*
* Register the comment and update the version
*/
if (!$this->hasDatabaseHistory($code, $version)) {
foreach ($comments as $comment) {
$this->applyDatabaseComment($code, $version, $comment);
}
}
$this->setDatabaseVersion($code, $version);
};
if (is_null($this->notesOutput)) {
$updateFn();
return;
}
$this->write(Task::class, sprintf(
'<info>%s</info>%s',
str_pad($version . ':', 10),
(strlen($comments[0]) > 120) ? substr($comments[0], 0, 120) . '...' : $comments[0]
), $updateFn);
}
/**
* Removes and packs down a plugin from the system. Files are left intact.
* If the $stopOnVersion parameter is specified, the process stops after
* the specified version is rolled back.
*
* @param mixed $plugin Either the identifier of a plugin as a string, or a Plugin class.
* @param string $stopOnVersion
* @param bool $stopCurrentVersion
* @return bool
*/
public function removePlugin($plugin, $stopOnVersion = null, $stopCurrentVersion = false)
{
$code = is_string($plugin) ? $plugin : $this->pluginManager->getIdentifier($plugin);
if (!$this->hasVersionFile($code)) {
return false;
}
$pluginHistory = $this->getDatabaseHistory($code);
$pluginHistory = array_reverse($pluginHistory);
$stopOnNextVersion = false;
$newPluginVersion = null;
try {
foreach ($pluginHistory as $history) {
if ($stopCurrentVersion && $stopOnVersion === $history->version) {
$newPluginVersion = $history->version;
break;
}
if ($stopOnNextVersion && $history->version !== $stopOnVersion) {
// Stop if the $stopOnVersion value was found and
// this is a new version. The history could contain
// multiple items for a single version (comments and scripts).
$newPluginVersion = $history->version;
break;
}
if ($history->type == self::HISTORY_TYPE_COMMENT) {
$this->removeDatabaseComment($code, $history->version);
} elseif ($history->type == self::HISTORY_TYPE_SCRIPT) {
$this->removeDatabaseScript($code, $history->version, $history->detail);
}
if ($stopOnVersion === $history->version) {
$stopOnNextVersion = true;
}
}
} catch (\Exception $exception) {
$lastHistory = $this->getLastHistory($code);
if ($lastHistory) {
$this->setDatabaseVersion($code, $lastHistory->version);
}
throw $exception;
}
$this->setDatabaseVersion($code, $newPluginVersion);
if (isset($this->fileVersions[$code])) {
unset($this->fileVersions[$code]);
}
if (isset($this->databaseVersions[$code])) {
unset($this->databaseVersions[$code]);
}
if (isset($this->databaseHistory[$code])) {
unset($this->databaseHistory[$code]);
}
return true;
}
/**
* Deletes all records from the version and history tables for a plugin.
* @param string $pluginCode Plugin code
* @return void
*/
public function purgePlugin($pluginCode)
{
$versions = Db::table('system_plugin_versions')->where('code', $pluginCode);
if ($countVersions = $versions->count()) {
$versions->delete();
}
$history = Db::table('system_plugin_history')->where('code', $pluginCode);
if ($countHistory = $history->count()) {
$history->delete();
}
return ($countHistory + $countVersions) > 0;
}
//
// File representation
//
/**
* Returns the latest version of a plugin from its version file.
*/
protected function getLatestFileVersion($code)
{
$versionInfo = $this->getFileVersions($code);
if (!$versionInfo) {
return self::NO_VERSION_VALUE;
}
return trim(key(array_slice($versionInfo, -1, 1)));
}
/**
* Returns older versions up to a supplied version, ie. applied versions.
*/
protected function getOldFileVersions($code, $version = null)
{
if ($version === null) {
$version = self::NO_VERSION_VALUE;
}
$versions = $this->getFileVersions($code);
$maxVersions = 0;
foreach ($versions as $v => $details) {
if (version_compare($v, $version, '<=')) {
$maxVersions++;
}
}
return array_slice($versions, 0, $maxVersions);
}
/**
* Returns any new versions from a supplied version, ie. unapplied versions.
*/
protected function getNewFileVersions($code, $version = null)
{
if ($version === null) {
$version = self::NO_VERSION_VALUE;
}
$versions = $this->getFileVersions($code);
$position = array_search($version, array_keys($versions), true);
if ($position === false) {
return $versions;
}
return array_slice($versions, ++$position);
}
/**
* Returns all versions of a plugin from its version file.
*/
protected function getFileVersions($code)
{
if ($this->fileVersions !== null && array_key_exists($code, $this->fileVersions)) {
return $this->fileVersions[$code];
}
$versionFile = $this->getVersionFile($code);
$versionInfo = Yaml::withProcessor(new VersionYamlProcessor, function ($yaml) use ($versionFile) {
return $yaml->parseFile($versionFile);
});
if (!is_array($versionInfo)) {
$versionInfo = [];
}
$normalizedVersions = [];
foreach ($versionInfo as $version => $info) {
$normalizedVersions[$this->normalizeVersion($version)] = $info;
}
if ($normalizedVersions) {
uksort($normalizedVersions, function ($a, $b) {
return version_compare($a, $b);
});
}
return $this->fileVersions[$code] = $normalizedVersions;
}
/**
* Normalize a version identifier by removing the optional 'v' prefix
*/
protected function normalizeVersion(string $version): string
{
return ltrim($version, 'v');
}
/**
* Returns the absolute path to a version file for a plugin.
*/
protected function getVersionFile($code)
{
$versionFile = $this->pluginManager->getPluginPath($code) . '/updates/version.yaml';
return $versionFile;
}
/**
* Checks if a plugin has a version file.
*/
protected function hasVersionFile($code)
{
$versionFile = $this->getVersionFile($code);
return File::isFile($versionFile);
}
//
// Database representation
//
/**
* Returns the latest version of a plugin from the database.
*/
protected function getDatabaseVersion($code)
{
if ($this->databaseVersions === null) {
$this->databaseVersions = Db::table('system_plugin_versions')->lists('version', 'code');
}
if (!isset($this->databaseVersions[$code])) {
$this->databaseVersions[$code] = Db::table('system_plugin_versions')
->where('code', $code)
->value('version');
}
return $this->normalizeVersion((string) ($this->databaseVersions[$code] ?? self::NO_VERSION_VALUE));
}
/**
* Updates a plugin version in the database.
*/
protected function setDatabaseVersion($code, $version = null)
{
$currentVersion = $this->getDatabaseVersion($code);
if ($version && !$currentVersion) {
Db::table('system_plugin_versions')->insert([
'code' => $code,
'version' => $version,
'created_at' => new Carbon
]);
} elseif ($version && $currentVersion) {
Db::table('system_plugin_versions')->where('code', $code)->update([
'version' => $version,
'created_at' => new Carbon
]);
} elseif ($currentVersion) {
Db::table('system_plugin_versions')->where('code', $code)->delete();
}
$this->databaseVersions[$code] = $version;
}
/**
* Registers a database update comment in the history table.
*/
protected function applyDatabaseComment($code, $version, $comment)
{
Db::table('system_plugin_history')->insert([
'code' => $code,
'type' => self::HISTORY_TYPE_COMMENT,
'version' => $version,
'detail' => $comment,
'created_at' => new Carbon
]);
}
/**
* Removes a database update comment in the history table.
*/
protected function removeDatabaseComment($code, $version)
{
Db::table('system_plugin_history')
->where('code', $code)
->where('type', self::HISTORY_TYPE_COMMENT)
->where('version', $version)
->delete();
}
/**
* Registers a database update script in the history table.
*/
protected function applyDatabaseScript($code, $version, $script)
{
/*
* Execute the database PHP script
*/
$updateFile = $this->pluginManager->getPluginPath($code) . '/updates/' . $script;
if (!File::isFile($updateFile)) {
$this->write(Error::class, sprintf('Migration file "%s" not found.', $script));
return;
}
$this->updater->setUp($updateFile);
Db::table('system_plugin_history')->insert([
'code' => $code,
'type' => self::HISTORY_TYPE_SCRIPT,
'version' => $version,
'detail' => $script,
'created_at' => new Carbon
]);
}
/**
* Removes a database update script in the history table.
*/
protected function removeDatabaseScript($code, $version, $script)
{
/*
* Execute the database PHP script
*/
$updateFile = $this->pluginManager->getPluginPath($code) . '/updates/' . $script;
$this->updater->packDown($updateFile);
Db::table('system_plugin_history')
->where('code', $code)
->where('type', self::HISTORY_TYPE_SCRIPT)
->where('version', $version)
->where('detail', $script)
->delete();
}
/**
* Returns all the update history for a plugin.
*/
public function getDatabaseHistory($code)
{
if ($this->databaseHistory !== null && array_key_exists($code, $this->databaseHistory)) {
return $this->databaseHistory[$code];
}
$historyInfo = Db::table('system_plugin_history')
->where('code', $code)
->orderBy('id')
->get()
->all();
return $this->databaseHistory[$code] = $historyInfo;
}
/**
* Returns the last update history for a plugin.
*
* @param string $code The plugin identifier
* @return stdClass|null
*/
protected function getLastHistory($code)
{
return Db::table('system_plugin_history')
->where('code', $code)
->orderBy('id', 'DESC')
->first();
}
/**
* Checks if a plugin has an applied update version.
*/
protected function hasDatabaseHistory($code, $version, $script = null)
{
$historyInfo = $this->getDatabaseHistory($code);
if (!$historyInfo) {
return false;
}
foreach ($historyInfo as $history) {
if ($history->version != $version) {
continue;
}
if ($history->type == self::HISTORY_TYPE_COMMENT && !$script) {
return true;
}
if ($history->type == self::HISTORY_TYPE_SCRIPT && $history->detail == $script) {
return true;
}
}
return false;
}
//
// Notes
//
/**
* Writes output to the console using a Laravel CLI View component.
*
* @param \Illuminate\Console\View\Components\Component $component
* @param array $arguments
* @return static
*/
protected function write($component, ...$arguments)
{
if ($this->notesOutput !== null) {
with(new $component($this->notesOutput))->render(...$arguments);
}
return $this;
}
/**
* Writes output to the console.
*
* @param string $message
* @param bool $newline
* @return static
*/
protected function out($message, $newline = false)
{
if ($this->notesOutput !== null) {
$this->notesOutput->write($message, $newline);
}
return $this;
}
/**
* Sets an output stream for writing notes.
* @param Illuminate\Console\Command $output
* @return self
*/
public function setNotesOutput($output)
{
$this->notesOutput = $output;
return $this;
}
/**
* Extract script and comments from version details
* @return array
*/
protected function extractScriptsAndComments($details): array
{
if (is_array($details)) {
$fileNamePattern = "/^[a-z0-9\_\-\.\/\\\]+\.php$/i";
$comments = array_values(array_filter($details, function ($detail) use ($fileNamePattern) {
return !preg_match($fileNamePattern, $detail);
}));
$scripts = array_values(array_filter($details, function ($detail) use ($fileNamePattern) {
return preg_match($fileNamePattern, $detail);
}));
}
else {
$comments = (array)$details;
$scripts = [];
}
return [$comments, $scripts];
}
/**
* Get the currently installed version of the plugin.
*
* @param string|PluginBase $plugin Either the identifier of a plugin as a string, or a Plugin class.
* @return string
*/
public function getCurrentVersion($plugin): string
{
$code = $this->pluginManager->getIdentifier($plugin);
return $this->getDatabaseVersion($code);
}
/**
* Check if a certain version of the plugin exists in the plugin history database.
*
* @param string|PluginBase $plugin Either the identifier of a plugin as a string, or a Plugin class.
* @param string $version
* @return bool
*/
public function hasDatabaseVersion($plugin, string $version): bool
{
$code = $this->pluginManager->getIdentifier($plugin);
$histories = $this->getDatabaseHistory($code);
foreach ($histories as $history) {
if ($history->version === $version) {
return true;
}
}
return false;
}
/**
* Get last version note
*
* @param string|PluginBase $plugin
* @return string
*/
public function getCurrentVersionNote($plugin): string
{
$code = $this->pluginManager->getIdentifier($plugin);
$histories = $this->getDatabaseHistory($code);
$lastHistory = array_last(array_where($histories, function ($history) {
return $history->type === self::HISTORY_TYPE_COMMENT;
}));
return $lastHistory ? $lastHistory->detail : '';
}
}

View File

@@ -0,0 +1,67 @@
<?php namespace System\Classes;
use Winter\Storm\Parse\Processor\YamlProcessor;
/**
* "version.yaml" pre-processor class.
*
* Post-v3.x versions of the Symonfy/Yaml package use more recent versions of YAML spec, which breaks common
* implementations of our version file format. To maintain compatibility, this class will pre-process YAML
* contents from these files to work with Symfony/Yaml 4.0+.
*
* @author Winter CMS
*/
class VersionYamlProcessor extends YamlProcessor
{
/**
* @inheritDoc
*/
public function preprocess($text)
{
$lines = preg_split('/[\n\r]+/', $text, -1, PREG_SPLIT_NO_EMPTY);
foreach ($lines as $num => &$line) {
// Surround array keys with quotes if not already
$line = preg_replace_callback('/^\s*([\'"]{0}[^\'"\n\r\-:]+[\'"]{0})\s*:/m', function ($matches) {
return '"' . trim($matches[1]) . '":';
}, rtrim($line));
// Add quotes around any unquoted text following an array key
// specifically to ensure usage of !!! in unquoted comments does not fail
$line = preg_replace_callback('/^\s*([^\n\r\-:]+)\s*: +(?![\'"\s])(.*)/m', function ($matches) {
$key = $matches[1];
$value = str_replace('"', '\\"', $matches[2]);
return $key . ': "' . $value . '"';
}, $line);
// If this line is the continuance of a multi-line string, remove the quote from the previous line and
// continue the quote
if (
preg_match('/^(?!\s*(-|(.*?):\s*))([^\n\r]+)([^"]$)/m', $line)
&& substr($lines[$num - 1], -1) === '"'
) {
$lines[$num - 1] = substr($lines[$num - 1], 0, -1);
$line .= '"';
}
// Add quotes around any unquoted array items
$line = preg_replace_callback('/^(\s*-\s*)(?![\'" ])(.*)/m', function ($matches) {
$array = $matches[1];
$value = str_replace('"', '\\"', $matches[2]);
return $array . '"' . $value . '"';
}, $line);
}
$processed = implode("\n", $lines);
return $processed;
}
/**
* @inheritDoc
*/
public function process($parsed)
{
return $parsed;
}
}

View File

@@ -0,0 +1,297 @@
<?php
namespace System\Classes\Asset;
use Closure;
use Winter\Storm\Support\Traits\Singleton;
/**
* Asset Bundle manager.
*
* This class manages "asset bundles" registered by the core and plugins that are used by the
* [mix|vite]:create commands to generate & populate the required files for a given bundle.
* Bundles include information on the specific packages & versions required for the bundle
* to function in the context of the Winter package (plugin or theme) it is being used in,
* as well as dependencies specific to the desired compiler (e.g. mix or vite).
*
* @package winter\wn-system-module
* @author Jack Wilkinson <me@jackwilky.com>
* @copyright Winter CMS Maintainers
*/
class BundleManager
{
use Singleton;
protected const HANDLER_SETUP = '_setup';
protected const HANDLER_SCAFFOLD = '_scaffold';
/**
* List of packages available to install. Allows for `$compilerName` => [`CompilerSpecificPackage`]
*/
protected array $defaultPackages = [
'tailwind' => [
'tailwindcss' => '^3.4.0',
'@tailwindcss/forms' => '^0.5.3',
'@tailwindcss/typography' => '^0.5.2',
],
'vue' => [
'vue' => '^3.4.0',
'vite' => [
'@vitejs/plugin-vue' => '^5.0.5'
],
],
'react' => [
'react' => '^19.0.0',
'react-dom' => '^19.0.0',
'vite' => [
'@vitejs/plugin-react' => '^4.3.4',
],
],
];
/**
* List of registered asset bundles in the system
*/
protected array $registeredBundles = [];
/**
* Initialize the singleton
*/
public function init(): void
{
// Register the default bundles
$this->registerCallback(function (self $manager) {
$manager->registerBundles($this->defaultPackages);
$manager->registerSetupHandler('tailwind', function (string $packagePath, string $packageType) {
$this->writeFile(
$packagePath . '/tailwind.config.js',
$this->getFixture('tailwind/tailwind.' . $packageType . '.config.js.fixture')
);
$this->writeFile(
$packagePath . '/postcss.config.mjs',
$this->getFixture('tailwind/postcss.config.js.fixture')
);
});
$manager->registerSetupHandler('react', function (string $packagePath, string $packageType) use ($manager) {
if ($this->option('no-stubs')) {
return;
}
$this->writeFile(
$packagePath . '/assets/src/js/components/App.jsx',
$this->getFixture('react/App.jsx.fixture')
);
$this->writeFile(
$packagePath . '/assets/src/js/' . strtolower($this->argument('packageName')) . '.jsx',
$this->getFixture('react/package.jsx.fixture')
);
});
$manager->registerScaffoldHandler('tailwind', function (string $contents, string $contentType) {
return match ($contentType) {
'mix' => $contents . PHP_EOL . <<<JAVASCRIPT
mix.postCss('assets/src/css/{{packageName}}.css', 'assets/dist/css/{{packageName}}.css', [
require('postcss-import'),
require('tailwindcss'),
require('autoprefixer'),
]);
JAVASCRIPT,
'css' => $this->getFixture('css/tailwind.css.fixture'),
default => $contents
};
});
$manager->registerScaffoldHandler('vue', function (string $contents, string $contentType) {
return match ($contentType) {
'vite' => str_replace(
'}),',
<<<JAVASCRIPT
}),
vue({
template: {
transformAssetUrls: {
// The Vue plugin will re-write asset URLs, when referenced
// in Single File Components, to point to the Laravel web
// server. Setting this to `null` allows the Laravel plugin
// to instead re-write asset URLs to point to the Vite
// server instead.
base: null,
// The Vue plugin will parse absolute URLs and treat them
// as absolute paths to files on disk. Setting this to
// `false` will leave absolute URLs un-touched so they can
// reference assets in the public directory as expected.
includeAbsolute: false,
},
},
}),
JAVASCRIPT,
str_replace(
'import laravel from \'laravel-vite-plugin\';',
'import laravel from \'laravel-vite-plugin\';' . PHP_EOL . 'import vue from \'@vitejs/plugin-vue\';',
$contents
)
),
'mix' => str_replace(
'mix.js(\'assets/src/js/{{packageName}}.js\', \'assets/dist/js/{{packageName}}.js\');',
'mix.js(\'assets/src/js/{{packageName}}.js\', \'assets/dist/js/{{packageName}}.js\').vue({ version: 3 });',
$contents
),
'js' => $this->getFixture('js/vue.js.fixture'),
default => $contents
};
});
$manager->registerScaffoldHandler('react', function (string $contents, string $contentType) {
return match ($contentType) {
'vite' => str_replace(
'}),',
<<<JAVASCRIPT
}),
react(),
JAVASCRIPT,
str_replace(
'import laravel from \'laravel-vite-plugin\';',
'import laravel from \'laravel-vite-plugin\';' . PHP_EOL . 'import react from \'@vitejs/plugin-react\';',
$contents
)
),
'mix' => str_replace(
'mix.js(\'assets/src/js/{{packageName}}.js\', \'assets/dist/js/{{packageName}}.js\');',
'mix.js(\'assets/src/js/{{packageName}}.js\', \'assets/dist/js/{{packageName}}.js\').react();',
$contents
),
'js' => str_replace(
'{{packageName}}',
strtolower($this->argument('packageName')),
$this->getFixture('react/package.js.fixture')
),
default => $contents
};
});
});
}
/**
* Returns a list of the registered asset bundles.
*/
public function listRegisteredBundles(): array
{
return $this->registeredBundles;
}
/**
* Get all bundles configured
*/
public function getBundles(): array
{
return array_keys($this->listRegisteredBundles());
}
/**
* Get the packages for a bundle, with compiler specific packages
*/
public function getBundlePackages(string $name, string $assetType): array
{
$config = $this->listRegisteredBundles()[$name] ?? [];
$packages = [];
foreach ($config as $key => $value) {
// Skip handlers
if (in_array($key, [static::HANDLER_SETUP, static::HANDLER_SCAFFOLD])) {
continue;
}
// Merge in any compiler specific packages for the current compiler
if (is_array($value)) {
if ($key === $assetType) {
$packages = array_merge($packages, $value);
}
continue;
}
$packages[$key] = $value;
}
return $packages;
}
/**
* Registers a callback function that defines asset bundles. The callback function
* should register bundles by calling the manager's registerBundles() function.
* This instance is passed to the callback function as an argument. Usage:
*
* BundleManager::registerCallback(function ($manager) {
* $manager->registerAssetBundles([...]);
* });
*
*/
public function registerCallback(callable $callback): static
{
$callback($this);
return $this;
}
/**
* Registers asset bundles.
*/
public function registerBundles(array $definitions): static
{
foreach ($definitions as $name => $definition) {
$this->registerBundle($name, $definition);
}
return $this;
}
/**
* Registers a single asset bundle.
*/
public function registerBundle(string $name, array $definition): static
{
$this->registeredBundles[$name] = $definition;
return $this;
}
/**
* Registers a single bundle setup handler.
*/
public function registerSetupHandler(string $name, Closure $closure): static
{
$this->registeredBundles[$name][static::HANDLER_SETUP] = $closure;
return $this;
}
/**
* Registers a single bundle scaffold handler.
*/
public function registerScaffoldHandler(string $name, Closure $closure): static
{
$this->registeredBundles[$name][static::HANDLER_SCAFFOLD] = $closure;
return $this;
}
/**
* Gets the setup handler for a bundle.
*/
public function getSetupHandler(string $name): ?Closure
{
return $this->listRegisteredBundles()[$name][static::HANDLER_SETUP] ?? null;
}
/**
* Gets the scaffold handler for a bundle.
*/
public function getScaffoldHandler(string $name): ?Closure
{
return $this->listRegisteredBundles()[$name][static::HANDLER_SCAFFOLD] ?? null;
}
}

View File

@@ -0,0 +1,278 @@
<?php
namespace System\Classes\Asset;
use InvalidArgumentException;
use RuntimeException;
use Winter\Storm\Support\Facades\File;
/**
* PHP based interface for interacting with package.json files. This allows for modification of deps, devDeps, package
* name and workspaces.
*
* @package winter\wn-system-module
* @author Jack Wilkinson <me@jackwilky.com>
* @author Winter CMS
*/
class PackageJson
{
/**
* The contents of the package.json being modified
*/
protected array $data = [];
/**
* Create a new instance with optional path, loads file if file already exists
* @throws \JsonException
*/
public function __construct(
protected ?string $path = null
) {
if (File::exists($this->path)) {
// Test the json to insure it's valid
$json = json_decode(File::get($this->path), JSON_OBJECT_AS_ARRAY);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \JsonException('The contents of the file "' . $this->path . '" is not valid json.');
}
$this->data = $json;
}
}
/**
* Returns the package name if set
*/
public function getName(): ?string
{
return $this->data['name'] ?? null;
}
/**
* Sets the package name, throws `InvalidArgumentException` on invalid name
*/
public function setName(?string $name): static
{
if (is_null($name)) {
unset($this->data['name']);
return $this;
}
if ($name !== strtolower($name)) {
throw new InvalidArgumentException('Package names must be lower case');
}
if (preg_match('/^([._])/', $name)) {
throw new InvalidArgumentException('Package names must not start with . or _');
}
if (preg_match('/[~\'\"!()*]/', $name)) {
throw new InvalidArgumentException('Package names must not include special characters');
}
if (strlen($name) > 214) {
throw new InvalidArgumentException('Package names must not be longer than 214 characters');
}
if ($name !== trim($name)) {
throw new InvalidArgumentException('Package names must not include whitespace');
}
$this->data['name'] = $name;
return $this;
}
/**
* Checks if workspace package is set
*/
public function hasWorkspace(string $path): bool
{
return in_array($path, $this->data['workspaces']['packages'] ?? []);
}
/**
* Adds a new workspace, removes from ignored workspaces if present
*/
public function addWorkspace(string $path): static
{
if (!in_array($path, $this->data['workspaces']['packages'] ?? [])) {
$this->data['workspaces']['packages'][] = $path;
}
if (($key = array_search($path, $this->data['workspaces']['ignoredPackages'] ?? [])) !== false) {
// remove the package from ignored workspaces
unset($this->data['workspaces']['ignoredPackages'][$key]);
// reset keys
$this->data['workspaces']['ignoredPackages'] = array_values($this->data['workspaces']['ignoredPackages']);
}
// Sort the packages
asort($this->data['workspaces']['packages']);
$this->data['workspaces']['packages'] = array_values($this->data['workspaces']['packages']);
return $this;
}
/**
* Removes a workspace
*/
public function removeWorkspace(string $path): static
{
if (($key = array_search($path, $this->data['workspaces']['packages'] ?? [])) !== false) {
// remove the package from workspace packages
unset($this->data['workspaces']['packages'][$key]);
// reset keys
$this->data['workspaces']['packages'] = array_values($this->data['workspaces']['packages']);
}
return $this;
}
/**
* Check if package is ignored
*/
public function hasIgnoredPackage(string $path): bool
{
return in_array($path, $this->data['workspaces']['ignoredPackages'] ?? []);
}
/**
* Adds an ignored package, removes from workspaces if present
*/
public function addIgnoredPackage(string $path): static
{
if (!in_array($path, $this->data['workspaces']['ignoredPackages'] ?? [])) {
$this->data['workspaces']['ignoredPackages'][] = $path;
}
if (($key = array_search($path, $this->data['workspaces']['packages'] ?? [])) !== false) {
// remove the package from ignored workspaces
unset($this->data['workspaces']['packages'][$key]);
// reset keys
$this->data['workspaces']['packages'] = array_values($this->data['workspaces']['packages']);
}
// Sort the packages
asort($this->data['workspaces']['ignoredPackages']);
$this->data['workspaces']['ignoredPackages'] = array_values($this->data['workspaces']['ignoredPackages'] ?? []);
return $this;
}
/**
* Removes an ignored package
*/
public function removeIgnoredPackage(string $path): static
{
if (($key = array_search($path, $this->data['workspaces']['ignoredPackages'] ?? [])) !== false) {
// remove the package from workspace packages
unset($this->data['workspaces']['ignoredPackages'][$key]);
// reset keys
$this->data['workspaces']['ignoredPackages'] = array_values($this->data['workspaces']['ignoredPackages']);
}
return $this;
}
/**
* Checks if package.json has a dependency
*/
public function hasDependency(string $package): bool
{
return isset($this->data['dependencies'][$package]) || isset($this->data['devDependencies'][$package]);
}
/**
* Adds a dependency, supports adding to `dependencies` or `devDependencies` based on `$dev` and allows moving if
* `$overwrite` is set
*/
public function addDependency(string $package, string $version, bool $dev = false, bool $overwrite = false): static
{
// If the dep is defined already, but we are not overwriting, then exit
if (
(isset($this->data['dependencies'][$package]) || isset($this->data['devDependencies'][$package]))
&& !$overwrite
) {
return $this;
}
// Clear any existing settings because we are overwriting
$this->removeDependency($package);
// Define the dep
$this->data[$dev ? 'devDependencies' : 'dependencies'][$package] = $version;
return $this;
}
/**
* Removes a package from both `dependencies` and `devDependencies`
*/
public function removeDependency(string $package): static
{
unset($this->data['dependencies'][$package], $this->data['devDependencies'][$package]);
return $this;
}
/**
* Returns if a script exists
*/
public function hasScript(string $name): bool
{
return isset($this->data['scripts'][$name]);
}
/**
* Returns the value of a script by name
*/
public function getScript(string $name): ?string
{
return $this->data['scripts'][$name] ?? null;
}
/**
* Adds a script
*/
public function addScript(string $name, string $script): static
{
$this->data['scripts'][$name] = $script;
return $this;
}
/**
* Removes a script by name
*/
public function removeScript(string $name): static
{
unset($this->data['scripts'][$name]);
return $this;
}
/**
* Returns the package.json contents as an array
*/
public function getContents(): array
{
return $this->data;
}
/**
* Returns the path of the package.json if set
*/
public function getPath(): ?string
{
return $this->path;
}
/**
* Saves the contents to a file, if the object was init'ed with a path it will save to the path, or can be
* overwritten with `$path`.
*/
public function save(?string $path = null): int
{
return File::put(
$path ?? $this->path ?? throw new RuntimeException('Unable to save, no path given'),
json_encode($this->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
);
}
}

View File

@@ -0,0 +1,414 @@
<?php
namespace System\Classes\Asset;
use Cms\Classes\Theme;
use InvalidArgumentException;
use System\Classes\PluginManager;
use Winter\Storm\Exception\SystemException;
use Winter\Storm\Filesystem\PathResolver;
use Winter\Storm\Support\Facades\Config;
use Winter\Storm\Support\Facades\File;
use Winter\Storm\Support\Str;
/**
* Package manager.
*
* This class manages compilable asset "packages" registered by modules, plugins, and themes that
* provide configurations for Node.js based compilers (e.g. mix or vite) to process.
*
* @package winter\wn-system-module
* @author Jack Wilkinson <me@jackwilky.com>
* @copyright Winter CMS Maintainers
*/
class PackageManager
{
use \Winter\Storm\Support\Traits\Singleton;
public const TYPE_THEME = 'theme';
public const TYPE_MODULE = 'module';
public const TYPE_PLUGIN = 'plugin';
/**
* The filename that stores the package definition.
*/
protected PackageJson $packageJson;
/**
* @var array<string, array<string, string>> List of package types and registration methods
*/
protected array $compilableConfigs = [
'mix' => [
'configFile' => 'winter.mix.js'
],
'vite' => [
'configFile' => 'vite.config.mjs'
]
];
/**
* A list of packages registered for compiling.
*/
protected array $packages = [];
/**
* Registered callbacks.
*/
protected static array $callbacks = [];
/**
* Constructor.
*/
public function init(): void
{
$this->setPackageJsonPath(base_path('package.json'));
$packagePaths = [];
/*
* Get packages registered in plugins.
*
* In the Plugin.php file for your plugin, you can define the `registerMixPackages` or `registerVitePackages`
* method and return an array, with the name of the package being the key, and the build config path - relative
* to the plugin directory - as the value.
*
* Example:
*
* public function registerMixPackages(): array
* {
* return [
* 'package-name-1' => 'winter.mix.js',
* 'package-name-2' => 'assets/js/build.js',
* ];
* }
*
* public function registerVitePackages(): array
* {
* return [
* 'package-name-1' => 'vite.config.mjs',
* 'package-name-2' => 'assets/js/build.js',
* ];
* }
*/
foreach ($this->compilableConfigs as $type => $config) {
$packages = PluginManager::instance()->getRegistrationMethodValues(
$this->getRegistrationMethod($type)
);
if (count($packages)) {
foreach ($packages as $pluginCode => $packageArray) {
if (!is_array($packageArray)) {
continue;
}
foreach ($packageArray as $name => $package) {
$this->registerPackage(
$name,
PluginManager::instance()->getPluginPath($pluginCode) . '/' . $package,
$type
);
}
}
}
// Get the currently enabled modules
$enabledModules = Config::get('cms.loadModules', []);
if (in_array('Cms', $enabledModules)) {
// Allow current theme to define mix assets
$theme = Theme::getActiveTheme();
if (!is_null($theme)) {
$mix = $theme->getConfigValue($type, []);
if (count($mix)) {
foreach ($mix as $name => $file) {
$this->registerPackage($name, $theme->getPath() . '/' . $file, $type);
}
}
}
}
// Search modules for compilable packages to autoregister
foreach ($enabledModules as $module) {
$module = strtolower($module);
$path = base_path('modules' . DIRECTORY_SEPARATOR . $module) . DIRECTORY_SEPARATOR . $config['configFile'];
if (File::exists($path)) {
$packagePaths[$type]["module-$module"] = $path;
}
}
// Search plugins for compilable packages to autoregister
$plugins = PluginManager::instance()->getPlugins();
foreach ($plugins as $plugin) {
$path = $plugin->getPluginPath() . '/' . $config['configFile'];
if (File::exists($path)) {
$packagePaths[$type][$plugin->getPluginIdentifier()] = $path;
}
}
// Search themes for compilable packages to autoregister
if (in_array('Cms', $enabledModules)) {
$themes = Theme::all();
foreach ($themes as $theme) {
$path = $theme->getPath() . '/' . $config['configFile'];
if (File::exists($path)) {
$packagePaths[$type]["theme-" . $theme->getId()] = $path;
}
}
}
}
// Register the autodiscovered compilable packages
foreach ($packagePaths as $type => $packages) {
foreach ($packages as $package => $path) {
try {
$this->registerPackage($package, $path, $type);
} catch (SystemException $e) {
// Either the package name or the config file path have already been registered, skip.
continue;
}
}
}
}
/**
* Register a compilable config.
*/
public function registerCompilable(string $name, array $config): void
{
$this->compilableConfigs[$name] = $config;
}
/**
* Registers a callback for processing.
*/
public static function registerCallback(callable $callback): void
{
static::$callbacks[] = $callback;
}
/**
* Calls the deferred callbacks.
*/
public function fireCallbacks(): static
{
// Call callbacks
foreach (static::$callbacks as $callback) {
$callback($this);
}
return $this;
}
/**
* Returns the count of packages registered.
*/
public function getPackageCount(): int
{
return array_sum(array_map(fn ($packages) => count($packages), ...$this->packages));
}
/**
* Returns all packages registered.
*/
public function getPackages(string $type, bool $includeIgnored = false): array
{
$packages = $this->packages[$type] ?? [];
foreach ($packages as $index => $package) {
$packages[$index]['ignored'] = $this->isPackageIgnored($package['path']);
}
ksort($packages);
if (!$includeIgnored) {
return array_filter($packages, function ($package) {
return !($package['ignored'] ?? false);
});
}
return $packages;
}
/**
* Returns if package(s) is registered.
*/
public function hasPackage(string $name, bool $includeIgnored = false): bool
{
foreach ($this->packages ?? [] as $packages) {
foreach ($packages as $packageName => $package) {
if ($name === $packageName) {
if ((!$this->isPackageIgnored($package['path']) || $includeIgnored)) {
return true;
}
return false;
}
}
}
return false;
}
/**
* Returns package(s).
*/
public function getPackage(string $name, bool $includeIgnored = false): array
{
$results = [];
foreach ($this->packages ?? [] as $type => $packages) {
foreach ($packages as $packageName => $package) {
if (($name === $packageName)) {
if (!$this->isPackageIgnored($package['path']) || $includeIgnored) {
$results[] = $package + ['type' => $type];
}
}
}
}
return $results;
}
/**
* Registers an entity as a package for compilation.
*
* Entities can include plugins, components, themes, modules and much more.
*
* The name of the package is an alias that can be used to reference this package in other methods within this
* class.
*
* By default, the `PackageManager` class will look for a `package.json` file for Node dependencies, and a config
* file for the compilable configuration
*
* @param string $name The name of the package being registered
* @param string $path The path to the compilable JS configuration file (it must be inside of the base_path()). If there is a related package.json file
* then it is required to be present in the same directory as the config file
* @param string $type The type of compilable
* @throws SystemException
*/
public function registerPackage(string $name, string $path, string $type = 'mix'): void
{
// Symbolize the path
$path = File::symbolizePath($path);
// Normalize the arguments
$name = strtolower($name);
$resolvedPath = PathResolver::resolve($path);
$pinfo = pathinfo($resolvedPath);
$relativePath = Str::after($pinfo['dirname'], base_path() . DIRECTORY_SEPARATOR);
$configFile = $pinfo['basename'];
// Require $configFile to be a JS file
$extension = File::extension($configFile);
if (!in_array($extension, ['js', 'mjs'])) {
throw new SystemException(sprintf(
'Compilable configuration for package "%s" must be a JavaScript file ending with .js or .mjs',
$name
));
}
// Check that the package path exists
if (!File::exists(base_path($relativePath))) {
throw new InvalidArgumentException(sprintf(
'Cannot register "%s" as a compilable package; the "%s" path does not exist.',
$name,
base_path($relativePath)
));
}
$package = $relativePath . '/package.json';
$config = $relativePath . DIRECTORY_SEPARATOR . $configFile;
if (!File::exists(base_path($config))) {
throw new SystemException(sprintf(
'Cannot register "%s" as a compilable package; the config file "%s" does not exist.',
$name,
$config
));
}
// Check for any existing packages already registered under the provided name
if (isset($this->packages[$name])) {
throw new SystemException(sprintf(
'Cannot register "%s" as a compilable package; it has already been registered at %s.',
$name,
$this->packages[$name]['config']
));
}
// Check for any existing package that already registers the given compilable config path
foreach ($this->packages[$type] ?? [] as $packageName => $settings) {
if ($settings['config'] === $config) {
// If the package name is the same, we'll just discard the repeated registration
if ($packageName === $name) {
return;
}
throw new SystemException(sprintf(
'Cannot register "%s" (%s) as a compilable package; it has already been registered as %s.',
$name,
$config,
$packageName
));
}
}
// Register the package
$this->packages[$type][$name] = [
'path' => $relativePath,
'package' => $package,
'config' => $config
];
}
/**
* Returns an expected package type from its name
*/
public function getPackageTypeFromName(string $package): ?string
{
// Check if package could be a module
if (Str::startsWith($package, 'module-') && !in_array($package, ['system', 'backend', 'cms'])) {
return static::TYPE_MODULE;
}
// Check if package could be a theme
if (
in_array('Cms', Config::get('cms.loadModules'))
&& Str::startsWith($package, 'theme-')
&& Theme::exists(Str::after($package, 'theme-'))
) {
return static::TYPE_THEME;
}
// Check if a package could be a plugin
if (PluginManager::instance()->exists($package)) {
return static::TYPE_PLUGIN;
}
return null;
}
/**
* Set the package.json file path used for checking if packages are in workspaces or ignored
*/
public function setPackageJsonPath(string $packageJsonPath): static
{
$this->packageJson = new PackageJson($packageJsonPath);
return $this;
}
/**
* Returns the registration method for a compiler type
*/
protected function getRegistrationMethod(string $type): string
{
return sprintf('register%sPackages', ucfirst($type));
}
/**
* Check if the provided package is ignored.
*/
protected function isPackageIgnored(string $packagePath): bool
{
return $this->packageJson->hasIgnoredPackage($packagePath);
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace System\Classes\Asset;
use Illuminate\Foundation\Vite as LaravelVite;
use Illuminate\Support\Facades\App;
use Illuminate\Support\HtmlString;
use Winter\Storm\Exception\SystemException;
class Vite extends LaravelVite
{
/**
* Generate Vite tags for an entrypoint(s).
*
* @param string|array $entrypoints The list of entry points for Vite
* @param string|null $package The package name of the plugin or theme
* @param string|null $buildDirectory The Vite build directory
*
* @return HtmlString
*
* @throws SystemException
*/
public function __invoke($entrypoints, $package = null, ?string $buildDirectory = null)
{
if (!$package) {
throw new \InvalidArgumentException('A package must be passed');
}
$compilableAssetPackage = static::resolvePackage($package);
$this->useHotFile(base_path($compilableAssetPackage['path'] . '/assets/dist/hot'));
return parent::__invoke($entrypoints, $compilableAssetPackage['path'] . ($buildDirectory ?? '/assets/dist'));
}
/**
* @throws SystemException if the package could not be found
*/
protected static function resolvePackage(string $package): array
{
// Normalise the package name
$package = strtolower($package);
if (!($compilableAssetPackage = PackageManager::instance()->getPackages('vite', true)[$package] ?? null)) {
throw new SystemException('Unable to resolve package: ' . $package);
}
return $compilableAssetPackage;
}
/**
* Helper method to generate Vite tags for an entrypoint(s).
*
* @param string|array $entrypoints The list of entry points for Vite
* @param string $package The package name of the plugin or theme
* @param string|null $buildDirectory The Vite build directory
*
* @throws SystemException
*/
public static function tags(array|string $entrypoints, string $package, ?string $buildDirectory = null): HtmlString
{
return App::make(\Illuminate\Foundation\Vite::class)($entrypoints, $package, $buildDirectory);
}
/**
* Helper method to generate Vite React Refresh tag.
*
* @param string $package The package name of the plugin or theme
* @param string|null $buildDirectory The Vite build directory
*
* @throws SystemException
*/
public static function reactRefreshTag(string $package, ?string $buildDirectory = null): ?HtmlString
{
$compilableAssetPackage = static::resolvePackage($package);
return App::make(\Illuminate\Foundation\Vite::class)
->useHotFile(base_path($compilableAssetPackage['path'] . '/assets/dist/hot'))
->useBuildDirectory($compilableAssetPackage['path'] . ($buildDirectory ?? '/assets/dist'))
->reactRefresh();
}
}