feat: VivesPOS landing on Winter CMS 1.2 — theme + plugin + Dockerfile
Some checks are pending
Module sub-split / Sub-split (push) Waiting to run
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:
313
modules/cms/classes/Asset.php
Normal file
313
modules/cms/classes/Asset.php
Normal file
@@ -0,0 +1,313 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use File;
|
||||
use Lang;
|
||||
use Config;
|
||||
use Request;
|
||||
use ApplicationException;
|
||||
use ValidationException;
|
||||
use Cms\Helpers\File as FileHelper;
|
||||
use Winter\Storm\Extension\Extendable;
|
||||
use Winter\Storm\Filesystem\PathResolver;
|
||||
|
||||
/**
|
||||
* The CMS theme asset file class.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class Asset extends Extendable
|
||||
{
|
||||
/**
|
||||
* @var \Cms\Classes\Theme A reference to the CMS theme containing the object.
|
||||
*/
|
||||
protected $theme;
|
||||
|
||||
/**
|
||||
* @var string The container name inside the theme.
|
||||
*/
|
||||
protected $dirName = 'assets';
|
||||
|
||||
/**
|
||||
* @var string Specifies the file name corresponding the CMS object.
|
||||
*/
|
||||
public $fileName;
|
||||
|
||||
/**
|
||||
* @var string Specifies the file name, the CMS object was loaded from.
|
||||
*/
|
||||
protected $originalFileName;
|
||||
|
||||
/**
|
||||
* @var string Last modified time.
|
||||
*/
|
||||
public $mtime;
|
||||
|
||||
/**
|
||||
* @var string The entire file content.
|
||||
*/
|
||||
public $content;
|
||||
|
||||
/**
|
||||
* @var array The attributes that are mass assignable.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'fileName',
|
||||
'content'
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array Allowable file extensions.
|
||||
*/
|
||||
protected $allowedExtensions = [];
|
||||
|
||||
/**
|
||||
* @var bool Indicates if the model exists.
|
||||
*/
|
||||
public $exists = false;
|
||||
|
||||
/**
|
||||
* Creates an instance of the object and associates it with a CMS theme.
|
||||
* @param \Cms\Classes\Theme $theme Specifies the theme the object belongs to.
|
||||
*/
|
||||
public function __construct(Theme $theme)
|
||||
{
|
||||
$this->theme = $theme;
|
||||
|
||||
$this->allowedExtensions = self::getEditableExtensions();
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the object from a file.
|
||||
* This method is used in the CMS back-end. It doesn't use any caching.
|
||||
* @param \Cms\Classes\Theme $theme Specifies the theme the object belongs to.
|
||||
* @param string $fileName Specifies the file name, with the extension.
|
||||
* The file name can contain only alphanumeric symbols, dashes and dots.
|
||||
* @return mixed Returns a CMS object instance or null if the object wasn't found.
|
||||
*/
|
||||
public static function load($theme, $fileName)
|
||||
{
|
||||
return (new static($theme))->find($fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the theme datasource for the model.
|
||||
* @param \Cms\Classes\Theme|string $theme Specifies a parent theme.
|
||||
* @return $this
|
||||
*/
|
||||
public static function inTheme($theme)
|
||||
{
|
||||
if (is_string($theme)) {
|
||||
$theme = Theme::load($theme);
|
||||
}
|
||||
|
||||
return new static($theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single template by its file name.
|
||||
*/
|
||||
public function find(string $fileName): ?static
|
||||
{
|
||||
$filePath = $this->getFilePath($fileName);
|
||||
|
||||
if (!File::isFile($filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (($content = @File::get($filePath)) === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->fileName = $fileName;
|
||||
$this->originalFileName = $fileName;
|
||||
$this->mtime = File::lastModified($filePath);
|
||||
$this->content = $content;
|
||||
$this->exists = true;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the object attributes.
|
||||
* @param array $attributes A list of attributes to set.
|
||||
*/
|
||||
public function fill(array $attributes)
|
||||
{
|
||||
foreach ($attributes as $key => $value) {
|
||||
if (!in_array($key, $this->fillable)) {
|
||||
throw new ApplicationException(Lang::get(
|
||||
'cms::lang.cms_object.invalid_property',
|
||||
['name' => $key]
|
||||
));
|
||||
}
|
||||
|
||||
$this->$key = $value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the object to the disk.
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
$this->validateFileName();
|
||||
|
||||
$fullPath = $this->getFilePath();
|
||||
|
||||
if (File::isFile($fullPath) && $this->originalFileName !== $this->fileName) {
|
||||
throw new ApplicationException(Lang::get(
|
||||
'cms::lang.cms_object.file_already_exists',
|
||||
['name'=>$this->fileName]
|
||||
));
|
||||
}
|
||||
|
||||
$dirPath = $this->theme->getPath().'/'.$this->dirName;
|
||||
if (!file_exists($dirPath) || !is_dir($dirPath)) {
|
||||
if (!File::makeDirectory($dirPath, 0777, true, true)) {
|
||||
throw new ApplicationException(Lang::get(
|
||||
'cms::lang.cms_object.error_creating_directory',
|
||||
['name'=>$dirPath]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (($pos = strpos($this->fileName, '/')) !== false) {
|
||||
$dirPath = dirname($fullPath);
|
||||
|
||||
if (!is_dir($dirPath) && !File::makeDirectory($dirPath, 0777, true, true)) {
|
||||
throw new ApplicationException(Lang::get(
|
||||
'cms::lang.cms_object.error_creating_directory',
|
||||
['name'=>$dirPath]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$newFullPath = $fullPath;
|
||||
if (@File::put($fullPath, $this->content) === false) {
|
||||
throw new ApplicationException(Lang::get(
|
||||
'cms::lang.cms_object.error_saving',
|
||||
['name'=>$this->fileName]
|
||||
));
|
||||
}
|
||||
|
||||
if (strlen($this->originalFileName) && $this->originalFileName !== $this->fileName) {
|
||||
$fullPath = $this->getFilePath($this->originalFileName);
|
||||
|
||||
if (File::isFile($fullPath)) {
|
||||
@unlink($fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
clearstatcache();
|
||||
|
||||
$this->mtime = @File::lastModified($newFullPath);
|
||||
$this->originalFileName = $this->fileName;
|
||||
$this->exists = true;
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
$fileName = Request::input('fileName');
|
||||
$fullPath = $this->getFilePath($fileName);
|
||||
|
||||
$this->validateFileName($fileName);
|
||||
|
||||
if (File::exists($fullPath)) {
|
||||
if (!@File::delete($fullPath)) {
|
||||
throw new ApplicationException(Lang::get(
|
||||
'cms::lang.asset.error_deleting_file',
|
||||
['name' => $fileName]
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the supplied filename, extension and path.
|
||||
* @param string $fileName
|
||||
*/
|
||||
protected function validateFileName($fileName = null)
|
||||
{
|
||||
if ($fileName === null) {
|
||||
$fileName = $this->fileName;
|
||||
}
|
||||
|
||||
$fileName = trim($fileName);
|
||||
|
||||
if (!strlen($fileName)) {
|
||||
throw new ValidationException(['fileName' =>
|
||||
Lang::get('cms::lang.cms_object.file_name_required', [
|
||||
'allowed' => implode(', ', $this->allowedExtensions),
|
||||
'invalid' => pathinfo($fileName, PATHINFO_EXTENSION)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
if (!FileHelper::validateExtension($fileName, $this->allowedExtensions, false)) {
|
||||
throw new ValidationException(['fileName' =>
|
||||
Lang::get('cms::lang.cms_object.invalid_file_extension', [
|
||||
'allowed' => implode(', ', $this->allowedExtensions),
|
||||
'invalid' => pathinfo($fileName, PATHINFO_EXTENSION)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
if (!FileHelper::validatePath($fileName, null)) {
|
||||
throw new ValidationException(['fileName' =>
|
||||
Lang::get('cms::lang.cms_object.invalid_file', [
|
||||
'name' => $fileName
|
||||
])
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file name.
|
||||
* @return string
|
||||
*/
|
||||
public function getFileName()
|
||||
{
|
||||
return $this->fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute file path.
|
||||
* @param string $fileName Specifies the file name to return the path to.
|
||||
* @return string
|
||||
*/
|
||||
public function getFilePath($fileName = null)
|
||||
{
|
||||
if ($fileName === null) {
|
||||
$fileName = $this->fileName;
|
||||
}
|
||||
|
||||
$directory = $this->theme->getPath() . '/' . $this->dirName . '/';
|
||||
$filePath = $directory . $fileName;
|
||||
|
||||
// Limit paths to those under the theme's assets directory
|
||||
if (!PathResolver::within($filePath, $directory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return PathResolver::resolve($filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of editable asset extensions.
|
||||
* The list can be overridden with the cms.editableAssetTypes configuration option.
|
||||
* @return array
|
||||
*/
|
||||
public static function getEditableExtensions()
|
||||
{
|
||||
$defaultTypes = ['css', 'js', 'less', 'sass', 'scss'];
|
||||
|
||||
$configTypes = Config::get('cms.editableAssetTypes');
|
||||
if (!$configTypes) {
|
||||
return $defaultTypes;
|
||||
}
|
||||
|
||||
return $configTypes;
|
||||
}
|
||||
}
|
||||
581
modules/cms/classes/AutoDatasource.php
Normal file
581
modules/cms/classes/AutoDatasource.php
Normal file
@@ -0,0 +1,581 @@
|
||||
<?php
|
||||
|
||||
namespace Cms\Classes;
|
||||
|
||||
use ApplicationException;
|
||||
use Cache;
|
||||
use Exception;
|
||||
use Winter\Storm\Halcyon\Datasource\Datasource;
|
||||
use Winter\Storm\Halcyon\Datasource\DatasourceInterface;
|
||||
use Winter\Storm\Halcyon\Exception\DeleteFileException;
|
||||
use Winter\Storm\Halcyon\Model;
|
||||
use Winter\Storm\Halcyon\Processors\Processor;
|
||||
use Winter\Storm\Support\Facades\Config;
|
||||
|
||||
/**
|
||||
* Datasource that loads from other data sources automatically
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Luke Towers
|
||||
*/
|
||||
class AutoDatasource extends Datasource implements DatasourceInterface
|
||||
{
|
||||
/**
|
||||
* @var array The available datasource instances
|
||||
*/
|
||||
protected $datasources = [];
|
||||
|
||||
/**
|
||||
* @var string The cache key to use for this datasource instance
|
||||
*/
|
||||
protected $cacheKey = 'halcyon-datastore-auto';
|
||||
|
||||
/**
|
||||
* @var array Local cache of paths available in the datasources
|
||||
*/
|
||||
protected $pathCache = [];
|
||||
|
||||
/**
|
||||
* @var boolean Flag on whether the cache should respect refresh requests
|
||||
*/
|
||||
protected $allowCacheRefreshes = true;
|
||||
|
||||
/**
|
||||
* @var string The key for the datasource to perform CRUD operations on
|
||||
*/
|
||||
public $activeDatasourceKey = '';
|
||||
|
||||
/**
|
||||
* @var bool Flag to indicate that we're in "single datasource mode"
|
||||
*/
|
||||
protected $singleDatasourceMode = false;
|
||||
|
||||
/**
|
||||
* Create a new datasource instance.
|
||||
*
|
||||
* @param array $datasources Array of datasources to utilize. Lower indexes = higher priority ['datasourceName' => $datasource]
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(array $datasources, ?string $cacheKey = null)
|
||||
{
|
||||
$this->datasources = $datasources;
|
||||
|
||||
if ($cacheKey) {
|
||||
$this->cacheKey = $cacheKey;
|
||||
}
|
||||
|
||||
$this->activeDatasourceKey = array_keys($datasources)[0];
|
||||
|
||||
$this->populateCache();
|
||||
|
||||
$this->postProcessor = new Processor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a datasource to the end of the list of datasources
|
||||
*/
|
||||
public function appendDatasource(string $key, DatasourceInterface $datasource): void
|
||||
{
|
||||
$this->datasources[$key] = $datasource;
|
||||
$this->pathCache[] = $this->fetchPathCache($datasource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend a datasource to the beginning of the list of datasources
|
||||
*/
|
||||
public function prependDatasource(string $key, DatasourceInterface $datasource): void
|
||||
{
|
||||
$this->datasources = array_prepend($this->datasources, $datasource, $key);
|
||||
$this->pathCache = array_prepend($this->pathCache, $this->fetchPathCache($datasource), $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the in memory path cache map
|
||||
*/
|
||||
public function getPathCache(): array
|
||||
{
|
||||
return $this->pathCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the local cache of paths available in each datasource
|
||||
*
|
||||
* @param boolean $refresh Default false, set to true to force the cache to be rebuilt
|
||||
*/
|
||||
public function populateCache(bool $refresh = false): void
|
||||
{
|
||||
$pathCache = [];
|
||||
foreach ($this->datasources as $datasource) {
|
||||
// Allow AutoDatasource instances to handle their own internal caching
|
||||
if ($datasource instanceof AutoDatasource) {
|
||||
$datasource->populateCache($refresh);
|
||||
$pathCache[] = array_merge(...array_reverse($datasource->getPathCache()));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove any existing cache data
|
||||
if ($refresh && $this->allowCacheRefreshes) {
|
||||
Cache::forget($datasource->getPathsCacheKey());
|
||||
}
|
||||
|
||||
// Load the cache
|
||||
$pathCache[] = $this->fetchPathCache($datasource);
|
||||
}
|
||||
$this->pathCache = $pathCache;
|
||||
}
|
||||
|
||||
protected function fetchPathCache(DatasourceInterface $datasource): array
|
||||
{
|
||||
$pathCache = [];
|
||||
if (Config::get('app.debug', false)) {
|
||||
$pathCache = $datasource->getAvailablePaths();
|
||||
} else {
|
||||
$pathCache = Cache::rememberForever($datasource->getPathsCacheKey(), function () use ($datasource) {
|
||||
return $datasource->getAvailablePaths();
|
||||
});
|
||||
}
|
||||
return $pathCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check to see if the specified datasource has the provided Halcyon Model
|
||||
*/
|
||||
public function sourceHasModel(string $source, Model $model): bool
|
||||
{
|
||||
if (!$model->exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = false;
|
||||
|
||||
$sourcePaths = $this->getSourcePaths($source);
|
||||
|
||||
if (!empty($sourcePaths)) {
|
||||
// Generate the path
|
||||
list($name, $extension) = $model->getFileNameParts();
|
||||
$path = $this->makeFilePath($model->getObjectTypeDirName(), $name, $extension);
|
||||
|
||||
// Deleted paths are included as being handled by a datasource
|
||||
// The functionality built on this will need to make sure they
|
||||
// include deleted records when actually performing syncing actions
|
||||
if (isset($sourcePaths[$path])) {
|
||||
$result = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available paths for the specified datasource key
|
||||
*/
|
||||
public function getSourcePaths(string $source): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
$keys = array_keys($this->datasources);
|
||||
if (in_array($source, $keys)) {
|
||||
// Get the datasource's cache index key
|
||||
$cacheIndex = array_search($source, $keys);
|
||||
|
||||
// Return the available paths
|
||||
$result = $this->pathCache[$cacheIndex];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces all operations in a provided closure to run within a selected datasource.
|
||||
*
|
||||
* @throws ApplicationException if the provided datasource key doesn't exist
|
||||
*/
|
||||
public function usingSource(string $source, \Closure $closure): mixed
|
||||
{
|
||||
if (!array_key_exists($source, $this->datasources)) {
|
||||
throw new ApplicationException('Invalid datasource specified.');
|
||||
}
|
||||
|
||||
// Setup the datasource for single source mode
|
||||
$previousSource = $this->activeDatasourceKey;
|
||||
$this->activeDatasourceKey = $source;
|
||||
$this->singleDatasourceMode = true;
|
||||
|
||||
// Execute the callback
|
||||
$return = $closure->call($this);
|
||||
|
||||
// Restore the datasource to auto mode
|
||||
$this->singleDatasourceMode = false;
|
||||
$this->activeDatasourceKey = $previousSource;
|
||||
|
||||
return $return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the provided model to the specified datasource
|
||||
*/
|
||||
public function pushToSource(Model $model, string $source): void
|
||||
{
|
||||
$this->usingSource($source, function () use ($model) {
|
||||
$datasource = $this->getActiveDatasource();
|
||||
|
||||
// Get the path parts
|
||||
$dirName = $model->getObjectTypeDirName();
|
||||
list($fileName, $extension) = $model->getFileNameParts();
|
||||
|
||||
// Get the file content
|
||||
$content = $datasource->getPostProcessor()->processUpdate($model->newQuery(), []);
|
||||
|
||||
// Perform an update on the selected datasource (will insert if it doesn't exist)
|
||||
$this->update($dirName, $fileName, $extension, $content);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the provided model from the specified datasource
|
||||
*/
|
||||
public function removeFromSource(Model $model, string $source): void
|
||||
{
|
||||
$this->usingSource($source, function () use ($model) {
|
||||
$datasource = $this->getActiveDatasource();
|
||||
|
||||
// Get the path parts
|
||||
$dirName = $model->getObjectTypeDirName();
|
||||
list($fileName, $extension) = $model->getFileNameParts();
|
||||
|
||||
// Perform a forced delete on the selected datasource to ensure it's removed
|
||||
$this->forceDelete($dirName, $fileName, $extension);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate datasource for the provided path
|
||||
*/
|
||||
protected function getDatasourceForPath(string $path): DatasourceInterface
|
||||
{
|
||||
// Always return the active datasource when singleDatasourceMode is enabled
|
||||
if ($this->singleDatasourceMode) {
|
||||
return $this->getActiveDatasource();
|
||||
}
|
||||
|
||||
// Default to the last datasource provided
|
||||
$datasourceIndex = count($this->datasources) - 1;
|
||||
|
||||
$isDeleted = false;
|
||||
|
||||
foreach ($this->pathCache as $i => $paths) {
|
||||
if (isset($paths[$path])) {
|
||||
$datasourceIndex = $i;
|
||||
|
||||
// Set isDeleted to the inverse of the the path's existance flag
|
||||
$isDeleted = !$paths[$path];
|
||||
|
||||
// Break on first datasource that can handle the path
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isDeleted) {
|
||||
throw new Exception("$path is deleted");
|
||||
}
|
||||
|
||||
$datasourceIndex = array_keys($this->datasources)[$datasourceIndex];
|
||||
|
||||
return $this->datasources[$datasourceIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path cache entry for the provided path from the first datasource that reports it
|
||||
*
|
||||
* @return mixed The datasource's entry for this path, or null if no datasource reports it.
|
||||
* Database datasources report a last modified timestamp, other datasources
|
||||
* report `true`, and paths marked as deleted report `false`.
|
||||
*/
|
||||
protected function getPathCacheEntry(string $path): mixed
|
||||
{
|
||||
foreach ($this->pathCache as $paths) {
|
||||
if (isset($paths[$path])) {
|
||||
return $paths[$path];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all valid paths for the provided directory, removing any paths marked as deleted
|
||||
*
|
||||
* @param string $dirName
|
||||
* @param array $options Array of options, [
|
||||
* 'extensions' => ['htm', 'md', 'twig'], // Extensions to search for
|
||||
* 'fileMatch' => '*gr[ae]y', // Shell matching pattern to match the filename against using the fnmatch function
|
||||
* ];
|
||||
* @return array $paths ["$dirName/path/1.md", "$dirName/path/2.md"]
|
||||
*/
|
||||
protected function getValidPaths(string $dirName, array $options = []): array
|
||||
{
|
||||
// Initialize result set
|
||||
$paths = [];
|
||||
|
||||
// Reverse the order of the sources so that earlier
|
||||
// sources are prioritized over later sources
|
||||
$pathsCache = array_reverse($this->pathCache);
|
||||
|
||||
// Get paths available in the provided dirName, allowing proper prioritization of earlier datasources
|
||||
foreach ($pathsCache as $datasourceKey => $sourcePaths) {
|
||||
// Only look at the active datasource if singleDatasourceMode is enabled
|
||||
if ($this->singleDatasourceMode && $datasourceKey !== $this->activeDatasourceKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$paths = array_merge($paths, array_filter($sourcePaths, function ($path) use ($dirName, $options) {
|
||||
$basePath = $dirName . '/';
|
||||
|
||||
$inPath = starts_with($path, $basePath);
|
||||
|
||||
// Check the fileMatch if provided as an option
|
||||
$fnMatch = !empty($options['fileMatch']) ? fnmatch($options['fileMatch'], str_after($path, $basePath)) : true;
|
||||
|
||||
// Check the extension if provided as an option
|
||||
$validExt = !empty($options['extensions']) && is_array($options['extensions']) ? in_array(pathinfo($path, PATHINFO_EXTENSION), $options['extensions']) : true;
|
||||
|
||||
return $inPath && $fnMatch && $validExt;
|
||||
}, ARRAY_FILTER_USE_KEY));
|
||||
}
|
||||
|
||||
// Filter out 'deleted' paths:
|
||||
$paths = array_filter($paths, function ($value) {
|
||||
return (bool) $value;
|
||||
});
|
||||
|
||||
// Return just an array of paths
|
||||
return array_keys($paths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to make file path.
|
||||
*/
|
||||
protected function makeFilePath(string $dirName, string $fileName, string $extension): string
|
||||
{
|
||||
return ltrim($dirName . '/' . $fileName . '.' . $extension, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the datasource for use with CRUD operations
|
||||
*/
|
||||
protected function getActiveDatasource(): DatasourceInterface
|
||||
{
|
||||
return $this->datasources[$this->activeDatasourceKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function selectOne(string $dirName, string $fileName, string $extension): ?array
|
||||
{
|
||||
try {
|
||||
$path = $this->makeFilePath($dirName, $fileName, $extension);
|
||||
$result = $this->getDatasourceForPath($path)->selectOne($dirName, $fileName, $extension);
|
||||
|
||||
// if result = null, this means that
|
||||
// - a: The requested record doesn't exist
|
||||
// - b: The requested record exists, but is marked deleted
|
||||
// - c: The requested record is reported to exist in a datasource that it doesn't actually exist in
|
||||
if (is_null($result)) {
|
||||
foreach ($this->pathCache as $paths) {
|
||||
// If the path is reported to exist here (and isn't marked deleted) even though the previous attempt
|
||||
// returned nothing, then the paths cache needs to be rebuilt and we should try again
|
||||
if (@$paths[$path]) {
|
||||
$this->populateCache(true);
|
||||
$result = $this->getDatasourceForPath($path)->selectOne($dirName, $fileName, $extension);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception $ex) {
|
||||
$result = null;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function select(string $dirName, array $options = []): array
|
||||
{
|
||||
// Handle fileName listings through just the cache
|
||||
if (@$options['columns'] === ['fileName']) {
|
||||
// Return just filenames of the valid paths for this directory
|
||||
$results = array_values(array_map(function ($path) use ($dirName) {
|
||||
return ['fileName' => str_after($path, $dirName . '/')];
|
||||
}, $this->getValidPaths($dirName, $options)));
|
||||
|
||||
// Retrieve full listings from datasources directly
|
||||
} else {
|
||||
// Initialize result set
|
||||
$sourceResults = [];
|
||||
|
||||
// Reverse the order of the sources so that earlier
|
||||
// sources are prioritized over later sources
|
||||
$datasources = array_reverse($this->datasources);
|
||||
|
||||
foreach ($datasources as $datasource) {
|
||||
$sourceResults = array_merge($sourceResults, $datasource->select($dirName, $options));
|
||||
}
|
||||
|
||||
// Remove duplicate results prioritizing results from earlier datasources
|
||||
$sourceResults = collect($sourceResults)->keyBy('fileName');
|
||||
|
||||
// Get a list of valid filenames from the list of valid paths for this directory
|
||||
$validFiles = array_map(function ($path) use ($dirName) {
|
||||
return str_after($path, $dirName . '/');
|
||||
}, $this->getValidPaths($dirName, $options));
|
||||
|
||||
// Filter out deleted paths
|
||||
$results = array_values($sourceResults->filter(function ($value, $key) use ($validFiles) {
|
||||
return in_array($key, $validFiles);
|
||||
})->all());
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function insert(string $dirName, string $fileName, string $extension, string $content): int
|
||||
{
|
||||
// Insert only on the active datasource
|
||||
$result = $this->getActiveDatasource()->insert($dirName, $fileName, $extension, $content);
|
||||
|
||||
// Refresh the cache
|
||||
$this->populateCache(true);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function update(string $dirName, string $fileName, string $extension, string $content, $oldFileName = null, $oldExtension = null): int
|
||||
{
|
||||
$searchFileName = $oldFileName ?: $fileName;
|
||||
$searchExt = $oldExtension ?: $extension;
|
||||
|
||||
// Ensure that files that are being renamed have their old names marked as deleted prior to inserting the renamed file
|
||||
// Also ensure that the cache only gets updated at the end of this operation instead of twice, once here and again at the end
|
||||
if ($searchFileName !== $fileName || $searchExt !== $extension) {
|
||||
$this->allowCacheRefreshes = false;
|
||||
$this->delete($dirName, $searchFileName, $searchExt);
|
||||
$this->allowCacheRefreshes = true;
|
||||
}
|
||||
|
||||
$datasource = $this->getActiveDatasource();
|
||||
|
||||
if (!empty($datasource->selectOne($dirName, $searchFileName, $searchExt))) {
|
||||
$result = $datasource->update($dirName, $fileName, $extension, $content, $oldFileName, $oldExtension);
|
||||
} else {
|
||||
$result = $datasource->insert($dirName, $fileName, $extension, $content);
|
||||
}
|
||||
|
||||
// Refresh the cache
|
||||
$this->populateCache(true);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function delete(string $dirName, string $fileName, string $extension): bool
|
||||
{
|
||||
try {
|
||||
// Delete from only the active datasource
|
||||
if ($this->forceDeleting) {
|
||||
$success = $this->getActiveDatasource()->forceDelete($dirName, $fileName, $extension);
|
||||
} else {
|
||||
$success = $this->getActiveDatasource()->delete($dirName, $fileName, $extension);
|
||||
}
|
||||
}
|
||||
catch (Exception $ex) {
|
||||
// Only attempt to do an insert-delete when not force deleting the record
|
||||
if (!$this->forceDeleting) {
|
||||
// Check to see if this is a valid path to delete
|
||||
$path = $this->makeFilePath($dirName, $fileName, $extension);
|
||||
|
||||
if (in_array($path, $this->getValidPaths($dirName))) {
|
||||
// Retrieve the current record
|
||||
$record = $this->selectOne($dirName, $fileName, $extension);
|
||||
|
||||
// Insert the current record into the active datasource so we can mark it as deleted
|
||||
$this->insert($dirName, $fileName, $extension, $record['content']);
|
||||
|
||||
// Perform the deletion on the newly inserted record
|
||||
$success = $this->delete($dirName, $fileName, $extension);
|
||||
} else {
|
||||
throw (new DeleteFileException)->setInvalidPath($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the cache
|
||||
$this->populateCache(true);
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function lastModified(string $dirName, string $fileName, string $extension): ?int
|
||||
{
|
||||
$path = $this->makeFilePath($dirName, $fileName, $extension);
|
||||
|
||||
// Database datasources record modification times in the path cache, which lets the
|
||||
// Halcyon cache validate itself without querying the database on every request.
|
||||
// Anything else (filesystem sources report `true`, deleted paths report `false`)
|
||||
// falls through to the datasource so its modification time stays live.
|
||||
if (!$this->singleDatasourceMode && is_int($mtime = $this->getPathCacheEntry($path))) {
|
||||
return $mtime;
|
||||
}
|
||||
|
||||
return $this->getDatasourceForPath($path)->lastModified($dirName, $fileName, $extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function makeCacheKey($name = ''): string
|
||||
{
|
||||
$key = '';
|
||||
|
||||
foreach ($this->datasources as $datasource) {
|
||||
$key .= $datasource->makeCacheKey($name) . '-';
|
||||
}
|
||||
$key .= $name;
|
||||
|
||||
return hash('crc32b', $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getPathsCacheKey(): string
|
||||
{
|
||||
return $this->cacheKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getAvailablePaths(): array
|
||||
{
|
||||
$paths = [];
|
||||
$datasources = array_reverse($this->datasources);
|
||||
foreach ($datasources as $datasource) {
|
||||
$paths = array_merge($paths, $datasource->getAvailablePaths());
|
||||
}
|
||||
return $paths;
|
||||
}
|
||||
}
|
||||
493
modules/cms/classes/CmsCompoundObject.php
Normal file
493
modules/cms/classes/CmsCompoundObject.php
Normal file
@@ -0,0 +1,493 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use App;
|
||||
use Ini;
|
||||
use Lang;
|
||||
use Cache;
|
||||
use Config;
|
||||
use Cms\Components\ViewBag;
|
||||
use Cms\Helpers\Cms as CmsHelpers;
|
||||
use Winter\Storm\Halcyon\Processors\SectionParser;
|
||||
use Twig\Source as TwigSource;
|
||||
use ApplicationException;
|
||||
|
||||
/**
|
||||
* This is a base class for CMS objects that have multiple sections - pages, partials and layouts.
|
||||
* The class implements functionality for the compound object file parsing. It also provides a way
|
||||
* to access parameters defined in the INI settings section as the object properties.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class CmsCompoundObject extends CmsObject
|
||||
{
|
||||
/**
|
||||
* @var array Initialized components defined in the template file.
|
||||
*/
|
||||
public $components = [];
|
||||
|
||||
/**
|
||||
* @var array INI settings defined in the template file. Not to be confused
|
||||
* with the attribute called settings. In this array, components are bumped
|
||||
* to their own array inside the 'components' key.
|
||||
*/
|
||||
public $settings = [
|
||||
'components' => []
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array Contains the view bag properties.
|
||||
* This property is used by the page editor internally.
|
||||
*/
|
||||
public $viewBag = [];
|
||||
|
||||
/**
|
||||
* @var array The attributes that are mass assignable.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'markup',
|
||||
'settings',
|
||||
'code'
|
||||
];
|
||||
|
||||
/**
|
||||
* The methods that should be returned from the collection of all objects.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $passthru = [
|
||||
'lists',
|
||||
'where',
|
||||
'sortBy',
|
||||
'whereComponent',
|
||||
'withComponent'
|
||||
];
|
||||
|
||||
/**
|
||||
* @var bool Model supports code and settings sections.
|
||||
*/
|
||||
protected $isCompoundObject = true;
|
||||
|
||||
/**
|
||||
* @var array|null Cache for component properties.
|
||||
*/
|
||||
protected static $objectComponentPropertyMap;
|
||||
|
||||
/**
|
||||
* @var mixed Cache store for the getViewBag method.
|
||||
*/
|
||||
protected $viewBagCache = false;
|
||||
|
||||
/**
|
||||
* Triggered after the object is loaded.
|
||||
* @return void
|
||||
*/
|
||||
public function afterFetch()
|
||||
{
|
||||
$this->parseComponentSettings();
|
||||
$this->validateSettings();
|
||||
$this->parseSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered when the model is saved.
|
||||
* @return void
|
||||
*/
|
||||
public function beforeSave()
|
||||
{
|
||||
// Ignore line-ending only changes to the code property to avoid triggering safe mode
|
||||
// when no changes actually occurred, it was just the browser reformatting line endings
|
||||
if ($this->isDirty('code')) {
|
||||
$oldCode = str_replace("\n", "\r\n", str_replace("\r", '', $this->getOriginal('code')));
|
||||
$newCode = str_replace("\n", "\r\n", str_replace("\r", '', $this->code));
|
||||
if ($oldCode === $newCode) {
|
||||
$this->code = $this->getOriginal('code');
|
||||
}
|
||||
}
|
||||
|
||||
$this->checkSafeMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Collection instance.
|
||||
*
|
||||
* @param array $models
|
||||
* @return \Winter\Storm\Halcyon\Collection
|
||||
*/
|
||||
public function newCollection(array $models = [])
|
||||
{
|
||||
return new CmsObjectCollection($models);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the model is loaded with an invalid INI section, the invalid content will be
|
||||
* passed as a special attribute. Look for it, then locate the failure reason.
|
||||
* @return void
|
||||
*/
|
||||
protected function validateSettings()
|
||||
{
|
||||
if (isset($this->attributes[SectionParser::ERROR_INI])) {
|
||||
CmsException::mask($this, 200);
|
||||
Ini::parse($this->attributes[SectionParser::ERROR_INI]);
|
||||
CmsException::unmask();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the settings array.
|
||||
* Child classes can override this method in order to update the content
|
||||
* of the $settings property after the object is loaded from a file.
|
||||
* @return void
|
||||
*/
|
||||
protected function parseSettings()
|
||||
{
|
||||
$this->fillViewBagArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method checks if safe mode is enabled by config, and the code
|
||||
* attribute is modified and populated. If so an exception is thrown.
|
||||
* @return void
|
||||
*/
|
||||
protected function checkSafeMode()
|
||||
{
|
||||
if (CmsHelpers::safeModeEnabled() && $this->isDirty('code') && strlen(trim($this->code))) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.cms_object.safe_mode_enabled'));
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Components
|
||||
//
|
||||
|
||||
/**
|
||||
* Runs components defined in the settings
|
||||
* Process halts if a component returns a value
|
||||
* @return void
|
||||
*/
|
||||
public function runComponents()
|
||||
{
|
||||
foreach ($this->components as $component) {
|
||||
if ($event = $component->fireEvent('component.beforeRun', [], true)) {
|
||||
return $event;
|
||||
}
|
||||
|
||||
if ($result = $component->onRun()) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ($event = $component->fireEvent('component.run', [], true)) {
|
||||
return $event;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse component sections.
|
||||
* Replace the multiple component sections with a single "components"
|
||||
* element in the $settings property.
|
||||
* @return void
|
||||
*/
|
||||
protected function parseComponentSettings()
|
||||
{
|
||||
$this->settings = $this->getSettingsAttribute();
|
||||
|
||||
$manager = ComponentManager::instance();
|
||||
$components = [];
|
||||
foreach ($this->settings as $setting => $value) {
|
||||
if (!is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$settingParts = explode(' ', $setting);
|
||||
$settingName = $settingParts[0];
|
||||
|
||||
$components[$setting] = $value;
|
||||
unset($this->settings[$setting]);
|
||||
}
|
||||
|
||||
$this->settings['components'] = $components;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a component by its name.
|
||||
* This method is used only in the back-end and for internal system needs when
|
||||
* the standard way to access components is not an option.
|
||||
* @param string $componentName Specifies the component name.
|
||||
* @return \Cms\Classes\ComponentBase Returns the component instance or null.
|
||||
*/
|
||||
public function getComponent($componentName)
|
||||
{
|
||||
if (!($componentSection = $this->hasComponent($componentName))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ComponentManager::instance()->makeComponent(
|
||||
$componentName,
|
||||
null,
|
||||
$this->settings['components'][$componentSection]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the object has a component with the specified name.
|
||||
* @param string $componentName Specifies the component name.
|
||||
* @return mixed Return false or the full component name used on the page (it could include the alias).
|
||||
*/
|
||||
public function hasComponent($componentName)
|
||||
{
|
||||
$componentManager = ComponentManager::instance();
|
||||
$componentName = $componentManager->resolve($componentName);
|
||||
|
||||
foreach ($this->settings['components'] as $sectionName => $values) {
|
||||
$result = $sectionName;
|
||||
|
||||
if ($sectionName == $componentName) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$parts = explode(' ', $sectionName);
|
||||
if (count($parts) > 1) {
|
||||
$sectionName = trim($parts[0]);
|
||||
|
||||
if ($sectionName == $componentName) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
$sectionName = $componentManager->resolve($sectionName);
|
||||
if ($sectionName == $componentName) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns component property names and values.
|
||||
* This method implements caching and can be used in the run-time on the front-end.
|
||||
* @param string $componentName Specifies the component name.
|
||||
* @return array Returns an associative array with property names in the keys and property values in the values.
|
||||
*/
|
||||
public function getComponentProperties($componentName)
|
||||
{
|
||||
$key = md5($this->theme->getPath()).'component-properties';
|
||||
|
||||
if (self::$objectComponentPropertyMap !== null) {
|
||||
$objectComponentMap = self::$objectComponentPropertyMap;
|
||||
}
|
||||
else {
|
||||
$cached = Cache::get($key, false);
|
||||
$unserialized = $cached ? @unserialize(@base64_decode($cached)) : false;
|
||||
$objectComponentMap = $unserialized ?: [];
|
||||
if ($objectComponentMap) {
|
||||
self::$objectComponentPropertyMap = $objectComponentMap;
|
||||
}
|
||||
}
|
||||
|
||||
$objectCode = $this->getBaseFileName();
|
||||
|
||||
if (array_key_exists($objectCode, $objectComponentMap)) {
|
||||
if (array_key_exists($componentName, $objectComponentMap[$objectCode])) {
|
||||
return $objectComponentMap[$objectCode][$componentName];
|
||||
}
|
||||
|
||||
return [];
|
||||
} else {
|
||||
$objectComponentMap[$objectCode] = [];
|
||||
}
|
||||
|
||||
if (!isset($this->settings['components'])) {
|
||||
$objectComponentMap[$objectCode] = [];
|
||||
}
|
||||
else {
|
||||
foreach ($this->settings['components'] as $name => $settings) {
|
||||
$nameParts = explode(' ', $name);
|
||||
if (count($nameParts) > 1) {
|
||||
$name = trim($nameParts[0]);
|
||||
}
|
||||
|
||||
$component = $this->getComponent($name);
|
||||
if (!$component) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$componentProperties = [];
|
||||
$propertyDefinitions = $component->defineProperties();
|
||||
foreach ($propertyDefinitions as $propertyName => $propertyInfo) {
|
||||
$componentProperties[$propertyName] = $component->property($propertyName);
|
||||
}
|
||||
|
||||
$objectComponentMap[$objectCode][$name] = $componentProperties;
|
||||
}
|
||||
}
|
||||
|
||||
self::$objectComponentPropertyMap = $objectComponentMap;
|
||||
|
||||
$expiresAt = now()->addMinutes(Config::get('cms.parsedPageCacheTTL', 10));
|
||||
Cache::put($key, base64_encode(serialize($objectComponentMap)), $expiresAt);
|
||||
|
||||
if (array_key_exists($componentName, $objectComponentMap[$objectCode])) {
|
||||
return $objectComponentMap[$objectCode][$componentName];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the object cache.
|
||||
* @param \Cms\Classes\Theme $theme Specifies a parent theme.
|
||||
* @return void
|
||||
*/
|
||||
public static function clearCache($theme)
|
||||
{
|
||||
$key = md5($theme->getPath()).'component-properties';
|
||||
Cache::forget($key);
|
||||
}
|
||||
|
||||
//
|
||||
// View Bag
|
||||
//
|
||||
|
||||
/**
|
||||
* Returns the configured view bag component.
|
||||
* This method is used only in the back-end and for internal system needs when
|
||||
* the standard way to access components is not an option.
|
||||
* @return \Cms\Components\ViewBag Returns the view bag component instance.
|
||||
*/
|
||||
public function getViewBag()
|
||||
{
|
||||
if ($this->viewBagCache !== false) {
|
||||
return $this->viewBagCache;
|
||||
}
|
||||
|
||||
$componentName = 'viewBag';
|
||||
|
||||
if (!isset($this->settings['components'][$componentName])) {
|
||||
$viewBag = new ViewBag(null, []);
|
||||
$viewBag->name = $componentName;
|
||||
|
||||
return $this->viewBagCache = $viewBag;
|
||||
}
|
||||
|
||||
return $this->viewBagCache = $this->getComponent($componentName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies view bag properties to the view bag array.
|
||||
* This is required for the back-end editors.
|
||||
* @return void
|
||||
*/
|
||||
protected function fillViewBagArray()
|
||||
{
|
||||
$viewBag = $this->getViewBag();
|
||||
foreach ($viewBag->getProperties() as $name => $value) {
|
||||
$this->viewBag[$name] = $value;
|
||||
}
|
||||
|
||||
$this->fireEvent('cmsObject.fillViewBagArray');
|
||||
}
|
||||
|
||||
//
|
||||
// Twig
|
||||
//
|
||||
|
||||
/**
|
||||
* Returns the Twig content string
|
||||
* @return string
|
||||
*/
|
||||
public function getTwigContent()
|
||||
{
|
||||
return $this->markup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Twig node tree generated from the object's markup.
|
||||
* This method is used by the system internally and shouldn't
|
||||
* participate in the front-end request processing.
|
||||
* @link http://twig.sensiolabs.org/doc/internals.html Twig internals
|
||||
* @param mixed $markup Specifies the markup content.
|
||||
* Use FALSE to load the content from the markup section.
|
||||
* @return Twig\Node\ModuleNode A node tree
|
||||
*/
|
||||
public function getTwigNodeTree($markup = false)
|
||||
{
|
||||
$twig = App::make('twig.environment.cms');
|
||||
$stream = $twig->tokenize(new TwigSource($markup === false ? $this->markup : $markup, 'getTwigNodeTree'));
|
||||
return $twig->parse($stream);
|
||||
}
|
||||
|
||||
//
|
||||
// Magic
|
||||
//
|
||||
|
||||
/**
|
||||
* Implements getter functionality for visible properties defined in
|
||||
* the settings section or view bag array.
|
||||
*/
|
||||
public function __get($name)
|
||||
{
|
||||
if (is_array($this->settings) && array_key_exists($name, $this->settings)) {
|
||||
return $this->settings[$name];
|
||||
}
|
||||
|
||||
if (is_array($this->viewBag) && array_key_exists($name, $this->viewBag)) {
|
||||
return $this->viewBag[$name];
|
||||
}
|
||||
|
||||
return parent::__get($name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically set attributes on the model.
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed $value
|
||||
* @return void
|
||||
*/
|
||||
public function __set($key, $value)
|
||||
{
|
||||
parent::__set($key, $value);
|
||||
|
||||
if (array_key_exists($key, $this->settings)) {
|
||||
$this->settings[$key] = $this->attributes[$key];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an attribute exists on the object.
|
||||
*
|
||||
* @param string $key
|
||||
* @return bool
|
||||
*/
|
||||
public function __isset($key)
|
||||
{
|
||||
if (parent::__isset($key) === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isset($this->viewBag[$key]) === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isset($this->settings[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically handle calls into the query instance.
|
||||
*
|
||||
* @param string $method
|
||||
* @param array $parameters
|
||||
* @return mixed
|
||||
*/
|
||||
public function __call($method, $parameters)
|
||||
{
|
||||
if (in_array($method, $this->passthru)) {
|
||||
$collection = $this->get();
|
||||
return call_user_func_array([$collection, $method], $parameters);
|
||||
}
|
||||
|
||||
return parent::__call($method, $parameters);
|
||||
}
|
||||
}
|
||||
71
modules/cms/classes/CmsController.php
Normal file
71
modules/cms/classes/CmsController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use App;
|
||||
use Closure;
|
||||
use Illuminate\Routing\Controller as ControllerBase;
|
||||
|
||||
/**
|
||||
* This is the master controller for all front-end pages.
|
||||
* All requests that have not been picked up already by the router will end up here,
|
||||
* then the URL is passed to the front-end controller for processing.
|
||||
*
|
||||
* @see Cms\Classes\Controller Front-end controller class
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class CmsController extends ControllerBase
|
||||
{
|
||||
use \Winter\Storm\Extension\ExtendableTrait;
|
||||
|
||||
/**
|
||||
* @var array Behaviors implemented by this controller.
|
||||
*/
|
||||
public $implement;
|
||||
|
||||
/**
|
||||
* Instantiate a new CmsController instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->extendableConstruct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and serves the request using the primary controller.
|
||||
* @param string $url Specifies the requested page URL.
|
||||
* If the parameter is omitted, the current URL used.
|
||||
* @return BaseResponse Returns the response to the provided URL
|
||||
*/
|
||||
public function run($url = '/')
|
||||
{
|
||||
return App::make(Controller::class)->run($url);
|
||||
}
|
||||
|
||||
public function __call($name, $params)
|
||||
{
|
||||
if ($name === 'extend') {
|
||||
if (empty($params[0]) || !is_callable($params[0])) {
|
||||
throw new \InvalidArgumentException('The extend() method requires a callback parameter or closure.');
|
||||
}
|
||||
if ($params[0] instanceof \Closure) {
|
||||
return $params[0]->call($this, $params[1] ?? $this);
|
||||
}
|
||||
return \Closure::fromCallable($params[0])->call($this, $params[1] ?? $this);
|
||||
}
|
||||
|
||||
return $this->extendableCall($name, $params);
|
||||
}
|
||||
|
||||
public static function __callStatic($name, $params)
|
||||
{
|
||||
if ($name === 'extend') {
|
||||
if (empty($params[0])) {
|
||||
throw new \InvalidArgumentException('The extend() method requires a callback parameter or closure.');
|
||||
}
|
||||
self::extendableExtendCallback($params[0], $params[1] ?? false, $params[2] ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
return self::extendableCallStatic($name, $params);
|
||||
}
|
||||
}
|
||||
236
modules/cms/classes/CmsException.php
Normal file
236
modules/cms/classes/CmsException.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use File;
|
||||
use Twig\Error\Error as TwigError;
|
||||
use Winter\Storm\Exception\ApplicationException;
|
||||
use Winter\Storm\Halcyon\Processors\SectionParser;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The CMS exception class.
|
||||
* The exception class handles CMS related errors. Allows the masking of other exception types which
|
||||
* uses actual source CMS files -- instead of cached files -- for their error content.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class CmsException extends ApplicationException
|
||||
{
|
||||
/**
|
||||
* @var \Cms\Classes\CmsCompoundObject A reference to a CMS object used for masking errors.
|
||||
*/
|
||||
protected $compoundObject;
|
||||
|
||||
/**
|
||||
* @var array Collection of error codes for each error distinction.
|
||||
*/
|
||||
protected static $errorCodes = [
|
||||
100 => 'General',
|
||||
200 => 'INI Settings',
|
||||
300 => 'PHP Content',
|
||||
400 => 'Twig Template'
|
||||
];
|
||||
|
||||
/**
|
||||
* Creates the CMS exception object.
|
||||
* @param mixed $message The message to display as a string, or a CmsCompoundObject that is used
|
||||
* for using this exception as a mask for another exception type.
|
||||
* @param int $code Error code to specify the exception type:
|
||||
* Error 100: A general exception.
|
||||
* Error 200: Mask the exception as INI content.
|
||||
* Error 300: Mask the exception as PHP content.
|
||||
* Error 400: Mask the exception as Twig content.
|
||||
* @param Throwable $previous Previous exception.
|
||||
*/
|
||||
public function __construct($message = null, $code = 100, ?Throwable $previous = null)
|
||||
{
|
||||
if ($message instanceof CmsCompoundObject || $message instanceof ComponentPartial) {
|
||||
$this->compoundObject = $message;
|
||||
$message = '';
|
||||
}
|
||||
|
||||
if (isset(static::$errorCodes[$code])) {
|
||||
$this->errorType = static::$errorCodes[$code];
|
||||
}
|
||||
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks some conditions to confirm error has actually occurred
|
||||
* due to the CMS template code, not some external code. If the error
|
||||
* has occurred in external code, the function will return false. Otherwise return
|
||||
* true and modify the exception by overriding it's content, line and message values
|
||||
* to be accurate against a CMS object properties.
|
||||
* @param Throwable $exception The exception to modify.
|
||||
* @return bool
|
||||
*/
|
||||
public function processCompoundObject(Throwable $exception)
|
||||
{
|
||||
switch ($this->code) {
|
||||
case 200:
|
||||
$result = $this->processIni($exception);
|
||||
break;
|
||||
|
||||
case 300:
|
||||
$result = $this->processPhp($exception);
|
||||
break;
|
||||
|
||||
case 400:
|
||||
$result = $this->processTwig($exception);
|
||||
break;
|
||||
}
|
||||
if ($result !== false) {
|
||||
$this->file = $this->compoundObject->getFilePath();
|
||||
|
||||
if (File::isFile($this->file) && is_readable($this->file)) {
|
||||
$this->fileContent = @file($this->file);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override properties of an exception specific to the INI section
|
||||
* of a CMS object.
|
||||
* @param Throwable $exception The exception to modify.
|
||||
* @return bool
|
||||
*/
|
||||
protected function processIni(Throwable $exception)
|
||||
{
|
||||
$message = $exception->getMessage();
|
||||
|
||||
/*
|
||||
* Expecting: syntax error, unexpected '!' in Unknown on line 4
|
||||
*/
|
||||
if (!starts_with($message, 'syntax error')) {
|
||||
return false;
|
||||
}
|
||||
if (strpos($message, 'Unknown') === false) {
|
||||
return false;
|
||||
}
|
||||
if (strpos($exception->getFile(), 'Ini.php') === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
* Line number from parse_ini_string() error.
|
||||
* The last word should contain the line number.
|
||||
*/
|
||||
$parts = explode(' ', $message);
|
||||
$line = array_pop($parts);
|
||||
$this->line = (int)$line;
|
||||
|
||||
// Find where the ini settings section begins
|
||||
$offsetArray = SectionParser::parseOffset($this->compoundObject->getContent());
|
||||
$this->line += $offsetArray['settings'];
|
||||
|
||||
$this->message = $message;
|
||||
|
||||
// Account for line 0
|
||||
$this->line--;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override properties of an exception specific to the PHP section
|
||||
* of a CMS object.
|
||||
* @param Throwable $exception The exception to modify.
|
||||
* @return bool
|
||||
*/
|
||||
protected function processPhp(Throwable $exception)
|
||||
{
|
||||
/*
|
||||
* Fatal Error
|
||||
*/
|
||||
if ($exception instanceof \Symfony\Component\ErrorHandler\Error\FatalError) {
|
||||
$check = false;
|
||||
|
||||
// Expected: */modules/cms/classes/CodeParser.php(165) : eval()'d code line 7
|
||||
if (strpos($exception->getFile(), 'CodeParser.php')) {
|
||||
$check = true;
|
||||
}
|
||||
|
||||
// Expected: */storage/cms/cache/39/05/home.htm.php
|
||||
if (strpos($exception->getFile(), $this->compoundObject->getFileName() . '.php')) {
|
||||
$check = true;
|
||||
}
|
||||
|
||||
if (!$check) {
|
||||
return false;
|
||||
}
|
||||
/*
|
||||
* Errors occurring the PHP code base class (Cms\Classes\CodeBase)
|
||||
*/
|
||||
}
|
||||
else {
|
||||
$trace = $exception->getTrace();
|
||||
if (isset($trace[1]['class'])) {
|
||||
$class = $trace[1]['class'];
|
||||
if (!is_subclass_of($class, CodeBase::class)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->message = $exception->getMessage();
|
||||
|
||||
// Offset the php, namespace and bracket tags from the generated class.
|
||||
$this->line = $exception->getLine() - 3;
|
||||
|
||||
// Find where the php code section begins
|
||||
$offsetArray = SectionParser::parseOffset($this->compoundObject->getContent());
|
||||
$this->line += $offsetArray['code'];
|
||||
|
||||
// Account for line 0
|
||||
$this->line--;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override properties of an exception specific to the Twig section
|
||||
* of a CMS object.
|
||||
* @param Throwable $exception The exception to modify.
|
||||
* @return bool
|
||||
*/
|
||||
protected function processTwig(Throwable $exception)
|
||||
{
|
||||
// Must be a Twig related exception
|
||||
if (!$exception instanceof TwigError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->message = $exception->getRawMessage();
|
||||
$this->line = $exception->getTemplateLine();
|
||||
|
||||
// Find where the twig markup section begins
|
||||
$offsetArray = SectionParser::parseOffset($this->compoundObject->getContent());
|
||||
$this->line += $offsetArray['markup'];
|
||||
|
||||
// Account for line 0
|
||||
$this->line--;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Masks this exception with the details of the supplied. The error code for
|
||||
* this exception object will determine how the supplied exception is used.
|
||||
* Error 100: A general exception. Inherits \Winter\Storm\Exception\ExceptionBase::applyMask()
|
||||
* Error 200: Mask the exception as INI content.
|
||||
* Error 300: Mask the exception as PHP content.
|
||||
* Error 400: Mask the exception as Twig content.
|
||||
* @param Throwable $exception The exception to modify.
|
||||
* @return void
|
||||
*/
|
||||
public function applyMask(Throwable $exception)
|
||||
{
|
||||
if ($this->code == 100 || $this->processCompoundObject($exception) === false) {
|
||||
parent::applyMask($exception);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
365
modules/cms/classes/CmsObject.php
Normal file
365
modules/cms/classes/CmsObject.php
Normal file
@@ -0,0 +1,365 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use App;
|
||||
use Lang;
|
||||
use Event;
|
||||
use Config;
|
||||
use Exception;
|
||||
use ValidationException;
|
||||
use ApplicationException;
|
||||
use Cms\Contracts\CmsObject as CmsObjectContract;
|
||||
use Winter\Storm\Filesystem\PathResolver;
|
||||
use Winter\Storm\Halcyon\Model as HalcyonModel;
|
||||
|
||||
/**
|
||||
* This is a base class for all CMS objects - content files, pages, partials and layouts.
|
||||
* The class implements basic operations with file-based templates.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class CmsObject extends HalcyonModel implements CmsObjectContract
|
||||
{
|
||||
use \Winter\Storm\Halcyon\Traits\Validation;
|
||||
|
||||
/**
|
||||
* @var array The rules to be applied to the data.
|
||||
*/
|
||||
public $rules = [];
|
||||
|
||||
/**
|
||||
* @var array The array of custom attribute names.
|
||||
*/
|
||||
public $attributeNames = [];
|
||||
|
||||
/**
|
||||
* @var array The array of custom error messages.
|
||||
*/
|
||||
public $customMessages = [];
|
||||
|
||||
/**
|
||||
* @var int The maximum allowed path nesting level. The default value is 2,
|
||||
* meaning that files can only exist in the root directory, or in a
|
||||
* subdirectory. Set to null if any level is allowed.
|
||||
*/
|
||||
protected $maxNesting = null;
|
||||
|
||||
/**
|
||||
* @var array The attributes that are mass assignable.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'content'
|
||||
];
|
||||
|
||||
/**
|
||||
* @var bool Model supports code and settings sections.
|
||||
*/
|
||||
protected $isCompoundObject = false;
|
||||
|
||||
/**
|
||||
* @var \Cms\Classes\Theme A reference to the CMS theme containing the object.
|
||||
*/
|
||||
protected $themeCache;
|
||||
|
||||
/**
|
||||
* The "booting" method of the model.
|
||||
* @return void
|
||||
*/
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
static::bootDefaultTheme();
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot all of the bootable traits on the model.
|
||||
* @return void
|
||||
*/
|
||||
protected static function bootDefaultTheme()
|
||||
{
|
||||
$resolver = static::getDatasourceResolver();
|
||||
if ($resolver->getDefaultDatasource()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$defaultTheme = App::runningInBackend()
|
||||
? Theme::getEditThemeCode()
|
||||
: Theme::getActiveThemeCode();
|
||||
|
||||
Theme::load($defaultTheme);
|
||||
|
||||
$resolver->setDefaultDatasource($defaultTheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the object from a file.
|
||||
* This method is used in the CMS back-end. It doesn't use any caching.
|
||||
* @param mixed $theme Specifies the theme the object belongs to.
|
||||
* @param string $fileName Specifies the file name, with the extension.
|
||||
* The file name can contain only alphanumeric symbols, dashes and dots.
|
||||
*/
|
||||
public static function load($theme, $fileName): ?static
|
||||
{
|
||||
return static::inTheme($theme)->find($fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the object from a cache.
|
||||
* This method is used by the CMS in the runtime. If the cache is not found, it is created.
|
||||
* @param \Cms\Classes\Theme $theme Specifies the theme the object belongs to.
|
||||
* @param string $fileName Specifies the file name, with the extension.
|
||||
* @return static|null Returns a CMS object instance or null if the object wasn't found.
|
||||
*/
|
||||
public static function loadCached($theme, $fileName): ?static
|
||||
{
|
||||
return static::inTheme($theme)
|
||||
->remember(Config::get('cms.parsedPageCacheTTL', 1440))
|
||||
->find($fileName)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of objects in the specified theme.
|
||||
* This method is used internally by the system.
|
||||
* @param \Cms\Classes\Theme $theme Specifies a parent theme.
|
||||
* @param boolean $skipCache Indicates if objects should be reloaded from the disk bypassing the cache.
|
||||
* @return CmsObjectCollection Returns a collection of CMS objects.
|
||||
*/
|
||||
public static function listInTheme($theme, $skipCache = false)
|
||||
{
|
||||
$result = [];
|
||||
$instance = static::inTheme($theme);
|
||||
|
||||
if ($skipCache) {
|
||||
$result = $instance->get();
|
||||
} else {
|
||||
$items = $instance->newQuery()->lists('fileName');
|
||||
|
||||
$loadedItems = [];
|
||||
foreach ($items as $item) {
|
||||
$loaded = static::loadCached($theme, $item);
|
||||
if ($loaded) {
|
||||
$loadedItems[] = $loaded;
|
||||
}
|
||||
unset($loaded);
|
||||
}
|
||||
|
||||
$result = $instance->newCollection($loadedItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* @event cms.object.listInTheme
|
||||
* Provides opportunity to filter the items returned by a call to CmsObject::listInTheme()
|
||||
*
|
||||
* Parameters provided are `$cmsObject` (the object being listed) and `$objectList` (a collection of the CmsObjects being returned).
|
||||
* > Note: The `$objectList` provided is an object reference to a CmsObjectCollection, to make changes you must use object modifying methods.
|
||||
*
|
||||
* Example usage (filters all pages except for the 404 page on the CMS Maintenance mode settings page):
|
||||
*
|
||||
* // Extend only the Settings Controller
|
||||
* \System\Controllers\Settings::extend(function ($controller) {
|
||||
* // Listen for the cms.object.listInTheme event
|
||||
* \Event::listen('cms.object.listInTheme', function ($cmsObject, $objectList) {
|
||||
* // Get the current context of the Settings Manager to ensure we only affect what we need to affect
|
||||
* $context = \System\Classes\SettingsManager::instance()->getContext();
|
||||
* if ($context->owner === 'winter.cms' && $context->itemCode === 'maintenance_settings') {
|
||||
* // Double check that this is a Page List that we're modifying
|
||||
* if ($cmsObject instanceof \Cms\Classes\Page) {
|
||||
* // Perform filtering with an original-object modifying method as $objectList is passed by reference (being that it's an object)
|
||||
* foreach ($objectList as $index => $page) {
|
||||
* if ($page->url !== '/404') {
|
||||
* $objectList->forget($index);
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* });
|
||||
* });
|
||||
*/
|
||||
Event::fire('cms.object.listInTheme', [$instance, $result]);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the theme datasource for the model.
|
||||
* @param \Cms\Classes\Theme $theme Specifies a parent theme.
|
||||
* @return static
|
||||
*/
|
||||
public static function inTheme($theme)
|
||||
{
|
||||
if (is_string($theme)) {
|
||||
$theme = Theme::load($theme);
|
||||
}
|
||||
|
||||
return static::on($theme->getDirName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the object to the theme.
|
||||
*
|
||||
* @param array $options
|
||||
* @return bool
|
||||
*/
|
||||
public function save(?array $options = null)
|
||||
{
|
||||
try {
|
||||
parent::save($options);
|
||||
}
|
||||
catch (Exception $ex) {
|
||||
$this->throwHalcyonSaveException($ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CMS theme this object belongs to.
|
||||
* @return \Cms\Classes\Theme
|
||||
*/
|
||||
public function getThemeAttribute()
|
||||
{
|
||||
if ($this->themeCache !== null) {
|
||||
return $this->themeCache;
|
||||
}
|
||||
|
||||
$themeName = $this->getDatasourceName()
|
||||
?: static::getDatasourceResolver()->getDefaultDatasource();
|
||||
|
||||
return $this->themeCache = Theme::load($themeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full path to the template file corresponding to this object.
|
||||
* @param string $fileName
|
||||
* @return string
|
||||
*/
|
||||
public function getFilePath($fileName = null)
|
||||
{
|
||||
if ($fileName === null) {
|
||||
$fileName = $this->fileName;
|
||||
}
|
||||
|
||||
$directory = $this->theme->getPath() . '/' . $this->getObjectTypeDirName() . '/';
|
||||
$filePath = $directory . $fileName;
|
||||
|
||||
// Limit paths to those under the corresponding theme directory
|
||||
if (!PathResolver::within($filePath, $directory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return PathResolver::resolve($filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file name.
|
||||
* @return string
|
||||
*/
|
||||
public function getFileName()
|
||||
{
|
||||
return $this->fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file name without the extension.
|
||||
* @return string
|
||||
*/
|
||||
public function getBaseFileName()
|
||||
{
|
||||
$pos = strrpos($this->fileName, '.');
|
||||
if ($pos === false) {
|
||||
return $this->fileName;
|
||||
}
|
||||
|
||||
return substr($this->fileName, 0, $pos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for {{ page.id }} or {{ layout.id }} twig vars
|
||||
* Returns a unique string for this object.
|
||||
* @return string
|
||||
*/
|
||||
public function getId()
|
||||
{
|
||||
return str_replace('/', '-', $this->getBaseFileName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file content.
|
||||
* @return string
|
||||
*/
|
||||
public function getContent()
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Twig content string.
|
||||
* @return string
|
||||
*/
|
||||
public function getTwigContent()
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the key used by the Twig cache.
|
||||
* @return string
|
||||
*/
|
||||
public function getTwigCacheKey()
|
||||
{
|
||||
$key = $this->getFilePath();
|
||||
|
||||
if ($event = $this->fireEvent('cmsObject.getTwigCacheKey', compact('key'), true)) {
|
||||
$key = $event;
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
//
|
||||
// Internals
|
||||
//
|
||||
|
||||
/**
|
||||
* Converts an exception type thrown by Halcyon to a native CMS exception.
|
||||
* @param Exception $ex
|
||||
*/
|
||||
protected function throwHalcyonSaveException(Exception $ex)
|
||||
{
|
||||
if ($ex instanceof \Winter\Storm\Halcyon\Exception\MissingFileNameException) {
|
||||
throw new ValidationException([
|
||||
'fileName' => Lang::get('cms::lang.cms_object.file_name_required')
|
||||
]);
|
||||
}
|
||||
elseif ($ex instanceof \Winter\Storm\Halcyon\Exception\InvalidExtensionException) {
|
||||
throw new ValidationException(['fileName' =>
|
||||
Lang::get('cms::lang.cms_object.invalid_file_extension', [
|
||||
'allowed' => implode(', ', $ex->getAllowedExtensions()),
|
||||
'invalid' => $ex->getInvalidExtension()
|
||||
])
|
||||
]);
|
||||
}
|
||||
elseif ($ex instanceof \Winter\Storm\Halcyon\Exception\InvalidFileNameException) {
|
||||
throw new ValidationException([
|
||||
'fileName' => Lang::get('cms::lang.cms_object.invalid_file', ['name'=>$ex->getInvalidFileName()])
|
||||
]);
|
||||
}
|
||||
elseif ($ex instanceof \Winter\Storm\Halcyon\Exception\FileExistsException) {
|
||||
throw new ApplicationException(
|
||||
Lang::get('cms::lang.cms_object.file_already_exists', ['name' => $ex->getInvalidPath()])
|
||||
);
|
||||
}
|
||||
elseif ($ex instanceof \Winter\Storm\Halcyon\Exception\CreateDirectoryException) {
|
||||
throw new ApplicationException(
|
||||
Lang::get('cms::lang.cms_object.error_creating_directory', ['name' => $ex->getInvalidPath()])
|
||||
);
|
||||
}
|
||||
elseif ($ex instanceof \Winter\Storm\Halcyon\Exception\CreateFileException) {
|
||||
throw new ApplicationException(
|
||||
Lang::get('cms::lang.cms_object.error_saving', ['name' => $ex->getInvalidPath()])
|
||||
);
|
||||
}
|
||||
else {
|
||||
throw $ex;
|
||||
}
|
||||
}
|
||||
}
|
||||
119
modules/cms/classes/CmsObjectCollection.php
Normal file
119
modules/cms/classes/CmsObjectCollection.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use ApplicationException;
|
||||
use Winter\Storm\Support\Collection as CollectionBase;
|
||||
|
||||
/**
|
||||
* This class represents a collection of Cms Objects.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class CmsObjectCollection extends CollectionBase
|
||||
{
|
||||
/**
|
||||
* Returns objects that use the supplied component.
|
||||
* @param string|array $components
|
||||
* @param null|callback $callback
|
||||
* @return static
|
||||
*/
|
||||
public function withComponent($components, $callback = null)
|
||||
{
|
||||
return $this->filter(function ($object) use ($components, $callback) {
|
||||
$hasComponent = false;
|
||||
|
||||
foreach ((array) $components as $componentName) {
|
||||
if (!$callback && $object->hasComponent($componentName)) {
|
||||
$hasComponent = true;
|
||||
}
|
||||
|
||||
if ($callback && ($component = $object->getComponent($componentName))) {
|
||||
$hasComponent = call_user_func($callback, $component) ?: $hasComponent;
|
||||
}
|
||||
}
|
||||
|
||||
return $hasComponent;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns objects whose properties match the supplied value.
|
||||
*
|
||||
* Note that this deviates from Laravel 6's Illuminate\Support\Traits\EnumeratesValues::where() method signature,
|
||||
* which uses ($key, $operator = null, $value = null) as parameters and that this class extends.
|
||||
*
|
||||
* To ensure backwards compatibility with our current Halcyon functionality, this method retains the original
|
||||
* parameters and functions the same way as before, with handling for the $value and $strict parameters to ensure
|
||||
* they match the previously expected formats. This means that you cannot use operators for "where" queries on
|
||||
* CMS object collections.
|
||||
*
|
||||
* @param string $property
|
||||
* @param string $value
|
||||
* @param bool $strict
|
||||
* @return static
|
||||
*/
|
||||
public function where($property, $value = null, $strict = null)
|
||||
{
|
||||
if (empty($value) || !is_string($value)) {
|
||||
throw new ApplicationException('You must provide a string value to compare with when executing a "where" '
|
||||
. 'query for CMS object collections.');
|
||||
}
|
||||
|
||||
if (!isset($strict) || !is_bool($strict)) {
|
||||
$strict = true;
|
||||
}
|
||||
|
||||
return $this->filter(function ($object) use ($property, $value, $strict) {
|
||||
if (!array_key_exists($property, $object->settings)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $strict
|
||||
? $object->settings[$property] === $value
|
||||
: $object->settings[$property] == $value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns objects whose component properties match the supplied value.
|
||||
* @param mixed $components
|
||||
* @param string $property
|
||||
* @param string $value
|
||||
* @param bool $strict
|
||||
* @return static
|
||||
*/
|
||||
public function whereComponent($components, $property, $value, $strict = false)
|
||||
{
|
||||
return $this->filter(function ($object) use ($components, $property, $value, $strict) {
|
||||
|
||||
$hasComponent = false;
|
||||
|
||||
foreach ((array) $components as $componentName) {
|
||||
if (!$componentAlias = $object->hasComponent($componentName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$componentSettings = array_get($object->settings, 'components', []);
|
||||
|
||||
if (!array_key_exists($componentAlias, $componentSettings)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$settings = $componentSettings[$componentAlias];
|
||||
|
||||
if (!array_key_exists($property, $settings)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
($strict && $settings[$property] === $value) ||
|
||||
(!$strict && $settings[$property] == $value)
|
||||
) {
|
||||
$hasComponent = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $hasComponent;
|
||||
});
|
||||
}
|
||||
}
|
||||
168
modules/cms/classes/CodeBase.php
Normal file
168
modules/cms/classes/CodeBase.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use ArrayAccess;
|
||||
use Winter\Storm\Extension\Extendable;
|
||||
|
||||
/**
|
||||
* Parent class for PHP classes created for layout and page code sections.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class CodeBase extends Extendable implements ArrayAccess
|
||||
{
|
||||
/**
|
||||
* @var \Cms\Classes\Page Specifies the current page
|
||||
*/
|
||||
public $page;
|
||||
|
||||
/**
|
||||
* @var \Cms\Classes\Layout Specifies the current layout
|
||||
*/
|
||||
public $layout;
|
||||
|
||||
/**
|
||||
* @var \Cms\Classes\controller Specifies the CMS controller
|
||||
*/
|
||||
public $controller;
|
||||
|
||||
/**
|
||||
* Creates the object instance.
|
||||
* @param \Cms\Classes\Page $page Specifies the CMS page.
|
||||
* @param \Cms\Classes\Layout $layout Specifies the CMS layout.
|
||||
* @param \Cms\Classes\Controller $controller Specifies the CMS controller.
|
||||
*/
|
||||
public function __construct($page, $layout, $controller)
|
||||
{
|
||||
$this->page = $page;
|
||||
$this->layout = $layout;
|
||||
$this->controller = $controller;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* This event is triggered when all components are initialized and before AJAX is handled.
|
||||
* The layout's onInit method triggers before the page's onInit method.
|
||||
*/
|
||||
public function onInit()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* This event is triggered in the beginning of the execution cycle.
|
||||
* The layout's onStart method triggers before the page's onStart method.
|
||||
*/
|
||||
public function onStart()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* This event is triggered in the end of the execution cycle, but before the page is displayed.
|
||||
* The layout's onEnd method triggers after the page's onEnd method.
|
||||
*/
|
||||
public function onEnd()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* ArrayAccess implementation
|
||||
*/
|
||||
public function offsetSet($offset, $value): void
|
||||
{
|
||||
$this->controller->vars[$offset] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* ArrayAccess implementation
|
||||
*/
|
||||
public function offsetExists($offset): bool
|
||||
{
|
||||
return isset($this->controller->vars[$offset]);
|
||||
}
|
||||
|
||||
/**
|
||||
* ArrayAccess implementation
|
||||
*/
|
||||
public function offsetUnset($offset): void
|
||||
{
|
||||
unset($this->controller->vars[$offset]);
|
||||
}
|
||||
|
||||
/**
|
||||
* ArrayAccess implementation
|
||||
*/
|
||||
public function offsetGet($offset): mixed
|
||||
{
|
||||
return $this->controller->vars[$offset] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically handle calls into the controller instance.
|
||||
* @param string $method
|
||||
* @param array $parameters
|
||||
* @return mixed
|
||||
*/
|
||||
public function __call($method, $parameters)
|
||||
{
|
||||
if ($this->methodExists($method)) {
|
||||
return call_user_func_array([$this, $method], $parameters);
|
||||
}
|
||||
|
||||
return call_user_func_array([$this->controller, $method], $parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* This object is referenced as $this->page in Cms\Classes\ComponentBase,
|
||||
* so to avoid $this->page->page this method will proxy there. This is also
|
||||
* used as a helper for accessing controller variables/components easier
|
||||
* in the page code, eg. $this->foo instead of $this['foo']
|
||||
* @param string $name
|
||||
* @return void
|
||||
*/
|
||||
public function __get($name)
|
||||
{
|
||||
if (isset($this->page->components[$name]) || isset($this->layout->components[$name])) {
|
||||
return $this[$name];
|
||||
}
|
||||
|
||||
if (($value = $this->page->{$name}) !== null) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (array_key_exists($name, $this->controller->vars)) {
|
||||
return $this[$name];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* This will set a property on the CMS Page object.
|
||||
* @param string $name
|
||||
* @param mixed $value
|
||||
* @return void
|
||||
*/
|
||||
public function __set($name, $value)
|
||||
{
|
||||
return $this->page->{$name} = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* This will check if a property is set on the CMS Page object.
|
||||
* @param string $name
|
||||
* @return bool
|
||||
*/
|
||||
public function __isset($name)
|
||||
{
|
||||
if (isset($this->page->components[$name]) || isset($this->layout->components[$name])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isset($this->page->{$name})) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return array_key_exists($name, $this->controller->vars);
|
||||
}
|
||||
}
|
||||
382
modules/cms/classes/CodeParser.php
Normal file
382
modules/cms/classes/CodeParser.php
Normal file
@@ -0,0 +1,382 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use File;
|
||||
use Lang;
|
||||
use Cache;
|
||||
use Config;
|
||||
use SystemException;
|
||||
use Winter\Storm\Support\Str;
|
||||
|
||||
/**
|
||||
* Parses the PHP code section of CMS objects.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class CodeParser
|
||||
{
|
||||
/**
|
||||
* @var \Cms\Classes\CmsCompoundObject A reference to the CMS object being parsed.
|
||||
*/
|
||||
protected $object;
|
||||
|
||||
/**
|
||||
* @var string Contains a path to the CMS object's file being parsed.
|
||||
*/
|
||||
protected $filePath;
|
||||
|
||||
/**
|
||||
* @var mixed The internal cache, keeps parsed object information during a request.
|
||||
*/
|
||||
protected static $cache = [];
|
||||
|
||||
/**
|
||||
* @var string Key for the parsed PHP file information cache.
|
||||
*/
|
||||
protected $dataCacheKey = '';
|
||||
|
||||
/**
|
||||
* Creates the class instance
|
||||
* @param \Cms\Classes\CmsCompoundObject A reference to a CMS object to parse.
|
||||
*/
|
||||
public function __construct(CmsCompoundObject $object)
|
||||
{
|
||||
$this->object = $object;
|
||||
$this->filePath = $object->getFilePath();
|
||||
$this->dataCacheKey = Config::get('cache.codeParserDataCacheKey', 'cms-php-file-data');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the CMS object's PHP code section and returns an array with the following keys:
|
||||
* - className
|
||||
* - filePath (path to the parsed PHP file)
|
||||
* - offset (PHP section offset in the template file)
|
||||
* - source ('parser', 'request-cache', or 'cache')
|
||||
* @return array
|
||||
*/
|
||||
public function parse()
|
||||
{
|
||||
/*
|
||||
* If the object has already been parsed in this request return the cached data.
|
||||
*/
|
||||
if (array_key_exists($this->filePath, self::$cache)) {
|
||||
self::$cache[$this->filePath]['source'] = 'request-cache';
|
||||
return self::$cache[$this->filePath];
|
||||
}
|
||||
|
||||
/*
|
||||
* Try to load the parsed data from the cache
|
||||
*/
|
||||
$path = $this->getCacheFilePath();
|
||||
|
||||
$result = [
|
||||
'filePath' => $path,
|
||||
'className' => null,
|
||||
'source' => null,
|
||||
'offset' => 0
|
||||
];
|
||||
|
||||
/*
|
||||
* There are two types of possible caching scenarios, either stored
|
||||
* in the cache itself, or stored as a cache file. In both cases,
|
||||
* make sure the cache is not stale and use it.
|
||||
*/
|
||||
if (is_file($path)) {
|
||||
$cachedInfo = $this->getCachedFileInfo();
|
||||
$hasCache = $cachedInfo !== null;
|
||||
|
||||
/*
|
||||
* Valid cache, return result
|
||||
*/
|
||||
if ($hasCache && $cachedInfo['mtime'] == $this->object->mtime) {
|
||||
$result['className'] = $cachedInfo['className'];
|
||||
$result['source'] = 'cache';
|
||||
|
||||
return self::$cache[$this->filePath] = $result;
|
||||
}
|
||||
|
||||
/*
|
||||
* Cache expired, cache file not stale, refresh cache and return result
|
||||
*/
|
||||
if (!$hasCache && filemtime($path) >= $this->object->mtime) {
|
||||
$className = $this->extractClassFromFile($path);
|
||||
if ($className) {
|
||||
$result['className'] = $className;
|
||||
$result['source'] = 'file-cache';
|
||||
|
||||
$this->storeCachedInfo($result);
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result['className'] = $this->rebuild($path);
|
||||
$result['source'] = 'parser';
|
||||
|
||||
$this->storeCachedInfo($result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the current file cache.
|
||||
* @param string The path in which the cached file should be stored
|
||||
*/
|
||||
protected function rebuild($path)
|
||||
{
|
||||
$uniqueName = str_replace('.', '', uniqid('', true)).'_'.md5(mt_rand());
|
||||
$className = 'Cms'.$uniqueName.'Class';
|
||||
|
||||
$body = $this->object->code;
|
||||
$body = preg_replace('/^\s*function/m', 'public function', $body);
|
||||
|
||||
$namespaces = [];
|
||||
$pattern = '/(use\s+[a-z0-9_\\\\]+(\s+as\s+[a-z0-9_]+)?;(\r\n|\n)?)/mi';
|
||||
preg_match_all($pattern, $body, $namespaces);
|
||||
$body = preg_replace($pattern, '', $body);
|
||||
|
||||
$parentClass = $this->object->getCodeClassParent();
|
||||
if ($parentClass !== null) {
|
||||
$parentClass = ' extends '.$parentClass;
|
||||
}
|
||||
|
||||
$fileContents = '<?php '.PHP_EOL;
|
||||
|
||||
foreach ($namespaces[0] as $namespace) {
|
||||
// Only allow compound or aliased use statements
|
||||
if (str_contains($namespace, '\\') || str_contains($namespace, ' as ')) {
|
||||
$fileContents .= trim($namespace).PHP_EOL;
|
||||
}
|
||||
}
|
||||
|
||||
$fileContents .= 'class '.$className.$parentClass.PHP_EOL;
|
||||
$fileContents .= '{'.PHP_EOL;
|
||||
$fileContents .= trim($body).PHP_EOL;
|
||||
$fileContents .= '}'.PHP_EOL;
|
||||
|
||||
$this->makeDirectorySafe(dirname($path));
|
||||
|
||||
$this->writeContentSafe($path, $fileContents);
|
||||
|
||||
// Attempt to load the generated code file to ensure any errors are thrown
|
||||
// before the file is cached
|
||||
if (!class_exists($className)) {
|
||||
require_once $path;
|
||||
}
|
||||
|
||||
return $className;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the object's PHP file and returns the corresponding object.
|
||||
* @param \Cms\Classes\Page $page Specifies the CMS page.
|
||||
* @param \Cms\Classes\Layout $layout Specifies the CMS layout.
|
||||
* @param \Cms\Classes\Controller $controller Specifies the CMS controller.
|
||||
* @return mixed
|
||||
*/
|
||||
public function source($page, $layout, $controller)
|
||||
{
|
||||
$data = $this->parse();
|
||||
$className = $data['className'];
|
||||
|
||||
if (!class_exists($className)) {
|
||||
require_once $data['filePath'];
|
||||
}
|
||||
|
||||
if (!class_exists($className) && ($data = $this->handleCorruptCache($data))) {
|
||||
$className = $data['className'];
|
||||
}
|
||||
|
||||
return new $className($page, $layout, $controller);
|
||||
}
|
||||
|
||||
/**
|
||||
* In some rare cases the cache file will not contain the class
|
||||
* name we expect. When this happens, destroy the corrupt file,
|
||||
* flush the request cache, and repeat the cycle.
|
||||
* @return void
|
||||
*/
|
||||
protected function handleCorruptCache($data)
|
||||
{
|
||||
$path = array_get($data, 'filePath', $this->getCacheFilePath());
|
||||
|
||||
if (is_file($path)) {
|
||||
if (($className = $this->extractClassFromFile($path)) && class_exists($className)) {
|
||||
$data['className'] = $className;
|
||||
return $data;
|
||||
}
|
||||
|
||||
@unlink($path);
|
||||
}
|
||||
|
||||
unset(self::$cache[$this->filePath]);
|
||||
|
||||
return $this->parse();
|
||||
}
|
||||
|
||||
//
|
||||
// Cache
|
||||
//
|
||||
|
||||
/**
|
||||
* Stores result data inside cache.
|
||||
* @param array $result
|
||||
* @return void
|
||||
*/
|
||||
protected function storeCachedInfo($result)
|
||||
{
|
||||
$cacheItem = $result;
|
||||
$cacheItem['mtime'] = $this->object->mtime;
|
||||
|
||||
$cached = $this->getCachedInfo() ?: [];
|
||||
$cached[$this->filePath] = $cacheItem;
|
||||
|
||||
$expiresAt = now()->addMinutes(1440);
|
||||
Cache::put($this->dataCacheKey, base64_encode(serialize($cached)), $expiresAt);
|
||||
|
||||
self::$cache[$this->filePath] = $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns path to the cached parsed file
|
||||
*/
|
||||
protected function getCacheFilePath(): string
|
||||
{
|
||||
$pathSegments = [
|
||||
storage_path('cms' . DIRECTORY_SEPARATOR . 'cache'),
|
||||
trim(
|
||||
Str::after(
|
||||
pathinfo($this->filePath, PATHINFO_DIRNAME),
|
||||
base_path()
|
||||
),
|
||||
DIRECTORY_SEPARATOR
|
||||
),
|
||||
basename($this->filePath) . '.php',
|
||||
];
|
||||
|
||||
return implode(DIRECTORY_SEPARATOR, $pathSegments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns information about all cached files.
|
||||
* @return mixed Returns an array representing the cached data or NULL.
|
||||
*/
|
||||
protected function getCachedInfo()
|
||||
{
|
||||
$cached = Cache::get($this->dataCacheKey, false);
|
||||
|
||||
if (
|
||||
$cached !== false &&
|
||||
($cached = @unserialize(@base64_decode($cached))) !== false
|
||||
) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns information about a cached file
|
||||
* @return integer
|
||||
*/
|
||||
protected function getCachedFileInfo()
|
||||
{
|
||||
$cached = $this->getCachedInfo();
|
||||
|
||||
if ($cached !== null && array_key_exists($this->filePath, $cached)) {
|
||||
return $cached[$this->filePath];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
//
|
||||
// Helpers
|
||||
//
|
||||
|
||||
/**
|
||||
* Extracts the class name from a cache file
|
||||
* @return string
|
||||
*/
|
||||
protected function extractClassFromFile($path)
|
||||
{
|
||||
$fileContent = file_get_contents($path);
|
||||
$matches = [];
|
||||
$pattern = '/Cms\S+_\S+Class/';
|
||||
preg_match($pattern, $fileContent, $matches);
|
||||
|
||||
if (!empty($matches[0])) {
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes content with concurrency support and cache busting
|
||||
* This work is based on the Twig\Cache\FilesystemCache class
|
||||
*/
|
||||
protected function writeContentSafe($path, $content)
|
||||
{
|
||||
$count = 0;
|
||||
$tmpFile = tempnam(dirname($path), basename($path));
|
||||
|
||||
if (@file_put_contents($tmpFile, $content) === false) {
|
||||
throw new SystemException(Lang::get('system::lang.file.create_fail', ['name'=>$tmpFile]));
|
||||
}
|
||||
|
||||
while (!@rename($tmpFile, $path)) {
|
||||
usleep(rand(50000, 200000));
|
||||
|
||||
if ($count++ > 10) {
|
||||
throw new SystemException(Lang::get('system::lang.file.create_fail', ['name'=>$path]));
|
||||
}
|
||||
}
|
||||
|
||||
File::chmod($path);
|
||||
|
||||
/*
|
||||
* Compile cached file into bytecode cache
|
||||
*/
|
||||
if (Config::get('cms.forceBytecodeInvalidation', false)) {
|
||||
$opcache_enabled = ini_get('opcache.enable');
|
||||
$opcache_path = trim(ini_get('opcache.restrict_api'));
|
||||
|
||||
if (!empty($opcache_path) && !starts_with(__FILE__, $opcache_path)) {
|
||||
$opcache_enabled = false;
|
||||
}
|
||||
|
||||
if (function_exists('opcache_invalidate') && $opcache_enabled) {
|
||||
opcache_invalidate($path, true);
|
||||
}
|
||||
elseif (function_exists('apc_compile_file')) {
|
||||
apc_compile_file($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make directory with concurrency support
|
||||
*/
|
||||
protected function makeDirectorySafe($dir)
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
if (is_dir($dir)) {
|
||||
if (!is_writable($dir)) {
|
||||
throw new SystemException(Lang::get('system::lang.directory.create_fail', ['name'=>$dir]));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
while (!is_dir($dir) && !@mkdir($dir, 0777, true)) {
|
||||
usleep(rand(50000, 200000));
|
||||
|
||||
if ($count++ > 10) {
|
||||
throw new SystemException(Lang::get('system::lang.directory.create_fail', ['name'=>$dir]));
|
||||
}
|
||||
}
|
||||
|
||||
File::chmodRecursive($dir);
|
||||
}
|
||||
}
|
||||
336
modules/cms/classes/ComponentBase.php
Normal file
336
modules/cms/classes/ComponentBase.php
Normal file
@@ -0,0 +1,336 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use Str;
|
||||
use Lang;
|
||||
use Config;
|
||||
use Winter\Storm\Extension\Extendable;
|
||||
use BadMethodCallException;
|
||||
|
||||
/**
|
||||
* Component base class
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
abstract class ComponentBase extends Extendable
|
||||
{
|
||||
use \System\Traits\AssetMaker;
|
||||
use \System\Traits\EventEmitter;
|
||||
use \System\Traits\PropertyContainer;
|
||||
|
||||
/**
|
||||
* @var string A unique identifier for this component.
|
||||
*/
|
||||
public $id;
|
||||
|
||||
/**
|
||||
* @var string Alias used for this component.
|
||||
*/
|
||||
public $alias;
|
||||
|
||||
/**
|
||||
* @var string Component class name or class alias used in the component declaration in a template.
|
||||
*/
|
||||
public $name;
|
||||
|
||||
/**
|
||||
* @var boolean Determines whether the component is hidden from the back-end UI.
|
||||
*/
|
||||
public $isHidden = false;
|
||||
|
||||
/**
|
||||
* @var string Icon of the plugin that defines the component.
|
||||
* This field is used by the CMS internally.
|
||||
*/
|
||||
public $pluginIcon;
|
||||
|
||||
/**
|
||||
* @var string Component CSS class name for the back-end page/layout component list.
|
||||
* This field is used by the CMS internally.
|
||||
*/
|
||||
public $componentCssClass;
|
||||
|
||||
/**
|
||||
* @var boolean Determines whether Inspector can be used with the component.
|
||||
* This field is used by the CMS internally.
|
||||
*/
|
||||
public $inspectorEnabled = true;
|
||||
|
||||
/**
|
||||
* @var string Specifies the component directory name.
|
||||
*/
|
||||
protected $dirName;
|
||||
|
||||
/**
|
||||
* @var \Cms\Classes\Controller Controller object.
|
||||
*/
|
||||
protected $controller;
|
||||
|
||||
/**
|
||||
* @var \Cms\Classes\PageCode Page object object.
|
||||
*/
|
||||
protected $page;
|
||||
|
||||
/**
|
||||
* @var array A collection of external property names used by this component.
|
||||
*/
|
||||
protected $externalPropertyNames = [];
|
||||
|
||||
/**
|
||||
* Component constructor. Takes in the page or layout code section object
|
||||
* and properties set by the page or layout.
|
||||
* @param null|CodeBase $cmsObject
|
||||
* @param array $properties
|
||||
*/
|
||||
public function __construct(?CodeBase $cmsObject = null, $properties = [])
|
||||
{
|
||||
if ($cmsObject !== null) {
|
||||
$this->page = $cmsObject;
|
||||
$this->controller = $cmsObject->controller;
|
||||
}
|
||||
|
||||
$this->properties = $this->validateProperties($properties);
|
||||
|
||||
$className = Str::normalizeClassName(get_called_class());
|
||||
$this->dirName = strtolower(str_replace('\\', '/', $className));
|
||||
$this->assetPath = Config::get('cms.pluginsPath', '/plugins').dirname(dirname($this->dirName));
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns information about this component.
|
||||
*
|
||||
* This method must be defined in your component and, at a minimum, should return an array with two keys:
|
||||
*
|
||||
* - `name`: The name of your component.
|
||||
* - `description`: The description or purpose of your component.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
abstract public function componentDetails();
|
||||
|
||||
/**
|
||||
* Returns the absolute component path.
|
||||
*/
|
||||
public function getPath()
|
||||
{
|
||||
return plugins_path() . $this->dirName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executed when this component is first initialized, before AJAX requests.
|
||||
*/
|
||||
public function init()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Executed when this component is bound to a page or layout, part of
|
||||
* the page life cycle.
|
||||
*/
|
||||
public function onRun()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Executed when this component is rendered on a page or layout.
|
||||
*/
|
||||
public function onRender()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a requested partial in context of this component,
|
||||
* see Cms\Classes\Controller@renderPartial for usage.
|
||||
*/
|
||||
public function renderPartial()
|
||||
{
|
||||
$this->controller->setComponentContext($this);
|
||||
$result = call_user_func_array([$this->controller, 'renderPartial'], func_get_args());
|
||||
$this->controller->setComponentContext(null);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the event cycle when running an AJAX handler.
|
||||
* @return boolean Returns true if the handler was found. Returns false otherwise.
|
||||
*/
|
||||
public function runAjaxHandler($handler)
|
||||
{
|
||||
/**
|
||||
* @event cms.component.beforeRunAjaxHandler
|
||||
* Provides an opportunity to modify an AJAX request to a component before it is processed by the component
|
||||
*
|
||||
* The parameter provided is `$handler` (the requested AJAX handler to be run)
|
||||
*
|
||||
* Example usage (forwards AJAX handlers to a backend widget):
|
||||
*
|
||||
* Event::listen('cms.component.beforeRunAjaxHandler', function ((\Cms\Classes\ComponentBase) $component, (string) $handler) {
|
||||
* if (strpos($handler, '::')) {
|
||||
* list($componentAlias, $handlerName) = explode('::', $handler);
|
||||
* if ($componentAlias === $this->getBackendWidgetAlias()) {
|
||||
* return $this->backendControllerProxy->runAjaxHandler($handler);
|
||||
* }
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* Or
|
||||
*
|
||||
* $this->controller->bindEvent('component.beforeRunAjaxHandler', function ((string) $handler) {
|
||||
* if (strpos($handler, '::')) {
|
||||
* list($componentAlias, $handlerName) = explode('::', $handler);
|
||||
* if ($componentAlias === $this->getBackendWidgetAlias()) {
|
||||
* return $this->backendControllerProxy->runAjaxHandler($handler);
|
||||
* }
|
||||
* }
|
||||
* });
|
||||
*
|
||||
*/
|
||||
if ($event = $this->fireSystemEvent('cms.component.beforeRunAjaxHandler', [$handler])) {
|
||||
return $event;
|
||||
}
|
||||
|
||||
$result = $this->$handler();
|
||||
|
||||
/**
|
||||
* @event cms.component.runAjaxHandler
|
||||
* Provides an opportunity to modify an AJAX request to a component after it is processed by the component
|
||||
*
|
||||
* The parameters provided are `$handler` (the requested AJAX handler to be run) and `$result` (the result of the component processing the request)
|
||||
*
|
||||
* Example usage (Logs requests and their response):
|
||||
*
|
||||
* Event::listen('cms.component.beforeRunHandler', function ((\Cms\Classes\ComponentBase) $component, (string) $handler, (mixed) $result) {
|
||||
* if (in_array($handler, $interceptHandlers)) {
|
||||
* return 'request has been intercepted, original response: ' . json_encode($result);
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* Or
|
||||
*
|
||||
* $this->controller->bindEvent('componenet.beforeRunAjaxHandler', function ((string) $handler, (mixed) $result) {
|
||||
* if (in_array($handler, $interceptHandlers)) {
|
||||
* return 'request has been intercepted, original response: ' . json_encode($result);
|
||||
* }
|
||||
* });
|
||||
*
|
||||
*/
|
||||
if ($event = $this->fireSystemEvent('cms.component.runAjaxHandler', [$handler, $result])) {
|
||||
return $event;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
//
|
||||
// External properties
|
||||
//
|
||||
|
||||
/*
|
||||
* Description on how to access external property names.
|
||||
*
|
||||
* # When
|
||||
* pageNumber = "7"
|
||||
* $this->propertyName('pageNumber'); // Returns NULL
|
||||
* $this->paramName('pageNumber'); // Returns NULL
|
||||
*
|
||||
* # When
|
||||
* pageNumber = "{{ :page }}"
|
||||
*
|
||||
* $this->propertyName('pageNumber'); // Returns ":page"
|
||||
* $this->paramName('pageNumber'); // Returns "page"
|
||||
*
|
||||
* # When
|
||||
* pageNumber = "{{ page }}"
|
||||
*
|
||||
* $this->propertyName('pageNumber'); // Returns "page"
|
||||
* $this->paramName('pageNumber'); // Returns NULL
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sets names used by external properties.
|
||||
* @param array $names The key should be the property name,
|
||||
* the value should be the external property name.
|
||||
* @return void
|
||||
*/
|
||||
public function setExternalPropertyNames(array $names)
|
||||
{
|
||||
$this->externalPropertyNames = $names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an external property name.
|
||||
* @param string $name Property name
|
||||
* @param string $extName External property name
|
||||
* @return string
|
||||
*/
|
||||
public function setExternalPropertyName($name, $extName)
|
||||
{
|
||||
return $this->externalPropertyNames[$name] = $extName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the external property name when the property value is an external property reference.
|
||||
* Otherwise the default value specified is returned.
|
||||
* @param string $name The property name
|
||||
* @param mixed $default
|
||||
* @return string
|
||||
*/
|
||||
public function propertyName($name, $default = null)
|
||||
{
|
||||
return array_get($this->externalPropertyNames, $name, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the external property name when the property value is a routing parameter reference.
|
||||
* Otherwise the default value specified is returned.
|
||||
* @param string $name The property name
|
||||
* @param mixed $default
|
||||
* @return string
|
||||
*/
|
||||
public function paramName($name, $default = null)
|
||||
{
|
||||
if (($extName = $this->propertyName($name)) && substr($extName, 0, 1) == ':') {
|
||||
return substr($extName, 1);
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
//
|
||||
// Magic methods
|
||||
//
|
||||
|
||||
/**
|
||||
* Dynamically handle calls into the controller instance.
|
||||
* @param string $method
|
||||
* @param array $parameters
|
||||
* @return mixed
|
||||
*/
|
||||
public function __call($method, $parameters)
|
||||
{
|
||||
try {
|
||||
return parent::__call($method, $parameters);
|
||||
}
|
||||
catch (BadMethodCallException $ex) {
|
||||
}
|
||||
|
||||
if (isset($this->controller) && method_exists($this->controller, $method)) {
|
||||
return call_user_func_array([$this->controller, $method], $parameters);
|
||||
}
|
||||
|
||||
throw new BadMethodCallException(Lang::get('cms::lang.component.method_not_found', [
|
||||
'name' => get_class($this),
|
||||
'method' => $method
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the component's alias, used by __SELF__
|
||||
*/
|
||||
public function __toString()
|
||||
{
|
||||
return $this->alias;
|
||||
}
|
||||
}
|
||||
129
modules/cms/classes/ComponentHelpers.php
Normal file
129
modules/cms/classes/ComponentHelpers.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use Lang;
|
||||
|
||||
/**
|
||||
* Defines some component helpers for the CMS UI.
|
||||
*
|
||||
* @package winter\wn-system-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class ComponentHelpers
|
||||
{
|
||||
/**
|
||||
* Returns a component property configuration as a JSON string or array.
|
||||
* @param mixed $component The component object
|
||||
* @param boolean $addAliasProperty Determines if the Alias property should be added to the result.
|
||||
* @param boolean $returnArray Determines if the method should return an array.
|
||||
* @return string
|
||||
*/
|
||||
public static function getComponentsPropertyConfig($component, $addAliasProperty = true, $returnArray = false)
|
||||
{
|
||||
$result = [];
|
||||
|
||||
if ($addAliasProperty) {
|
||||
$property = [
|
||||
'property' => 'oc.alias',
|
||||
'title' => Lang::get('cms::lang.component.alias'),
|
||||
'description' => Lang::get('cms::lang.component.alias_description'),
|
||||
'type' => 'string',
|
||||
'validationPattern' => '^(@)?[a-zA-Z]+[0-9a-z\_]*$',
|
||||
'validationMessage' => Lang::get('cms::lang.component.validation_message'),
|
||||
'required' => true,
|
||||
'showExternalParam' => false
|
||||
];
|
||||
$result[] = $property;
|
||||
}
|
||||
|
||||
$properties = $component->defineProperties();
|
||||
if (is_array($properties)) {
|
||||
foreach ($properties as $name => $params) {
|
||||
$property = [
|
||||
'property' => $name,
|
||||
'title' => array_get($params, 'title', $name),
|
||||
'type' => array_get($params, 'type', 'string'),
|
||||
'showExternalParam' => array_get($params, 'showExternalParam', true)
|
||||
];
|
||||
|
||||
foreach ($params as $name => $value) {
|
||||
if (isset($property[$name])) {
|
||||
continue;
|
||||
}
|
||||
$property[$name] = $value;
|
||||
}
|
||||
|
||||
/*
|
||||
* Translate human values
|
||||
*/
|
||||
$translate = ['title', 'description', 'options', 'group', 'validationMessage'];
|
||||
foreach ($property as $name => $value) {
|
||||
if (!in_array($name, $translate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
array_walk($property[$name], function (&$_value, $key) {
|
||||
$_value = Lang::get($_value);
|
||||
});
|
||||
}
|
||||
else {
|
||||
$property[$name] = Lang::get($value);
|
||||
}
|
||||
}
|
||||
|
||||
$result[] = $property;
|
||||
}
|
||||
}
|
||||
|
||||
if ($returnArray) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return json_encode($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a component property values.
|
||||
* @param mixed $component The component object
|
||||
* @return mixed
|
||||
*/
|
||||
public static function getComponentPropertyValues($component)
|
||||
{
|
||||
$result = [];
|
||||
|
||||
$result['oc.alias'] = $component->alias;
|
||||
|
||||
$properties = $component->defineProperties();
|
||||
foreach ($properties as $name => $params) {
|
||||
$result[$name] = $component->property($name);
|
||||
}
|
||||
|
||||
return json_encode($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a component name.
|
||||
* @param mixed $component The component object
|
||||
* @return string
|
||||
*/
|
||||
public static function getComponentName($component)
|
||||
{
|
||||
$details = $component->componentDetails();
|
||||
$name = $details['name'] ?? 'cms::lang.component.unnamed';
|
||||
|
||||
return Lang::get($name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a component description.
|
||||
* @param mixed $component The component object
|
||||
* @return string
|
||||
*/
|
||||
public static function getComponentDescription($component)
|
||||
{
|
||||
$details = $component->componentDetails();
|
||||
$name = $details['description'] ?? 'cms::lang.component.no_description';
|
||||
|
||||
return Lang::get($name);
|
||||
}
|
||||
}
|
||||
239
modules/cms/classes/ComponentManager.php
Normal file
239
modules/cms/classes/ComponentManager.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use Str;
|
||||
use System\Classes\PluginManager;
|
||||
use SystemException;
|
||||
use Illuminate\Support\Facades\App;
|
||||
|
||||
/**
|
||||
* Component manager
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class ComponentManager
|
||||
{
|
||||
use \Winter\Storm\Support\Traits\Singleton;
|
||||
|
||||
/**
|
||||
* @var array Cache of registration callbacks.
|
||||
*/
|
||||
protected $callbacks = [];
|
||||
|
||||
/**
|
||||
* @var array An array where keys are codes and values are class names.
|
||||
*/
|
||||
protected $codeMap;
|
||||
|
||||
/**
|
||||
* @var array An array where keys are class names and values are codes.
|
||||
*/
|
||||
protected $classMap;
|
||||
|
||||
/**
|
||||
* @var array An array containing references to a corresponding plugin for each component class.
|
||||
*/
|
||||
protected $pluginMap;
|
||||
|
||||
/**
|
||||
* @var array A cached array of component details.
|
||||
*/
|
||||
protected $detailsCache;
|
||||
|
||||
/**
|
||||
* Scans each plugin an loads it's components.
|
||||
* @return void
|
||||
*/
|
||||
protected function loadComponents()
|
||||
{
|
||||
/*
|
||||
* Load module components
|
||||
*/
|
||||
foreach ($this->callbacks as $callback) {
|
||||
$callback($this);
|
||||
}
|
||||
|
||||
/*
|
||||
* Load plugin components
|
||||
*/
|
||||
$pluginManager = PluginManager::instance();
|
||||
$plugins = $pluginManager->getPlugins();
|
||||
|
||||
foreach ($plugins as $plugin) {
|
||||
$components = $plugin->registerComponents();
|
||||
if (!is_array($components)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($components as $className => $code) {
|
||||
$this->registerComponent($className, $code, $plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually registers a component for consideration. Usage:
|
||||
*
|
||||
* ComponentManager::registerComponents(function ($manager) {
|
||||
* $manager->registerComponent('Winter\Demo\Components\Test', 'testComponent');
|
||||
* });
|
||||
*
|
||||
* @param callable $definitions
|
||||
* @return array Array values are class names.
|
||||
*/
|
||||
public function registerComponents(callable $definitions)
|
||||
{
|
||||
$this->callbacks[] = $definitions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a single component.
|
||||
*/
|
||||
public function registerComponent($className, $code = null, $plugin = null)
|
||||
{
|
||||
if (!$this->classMap) {
|
||||
$this->classMap = [];
|
||||
}
|
||||
|
||||
if (!$this->codeMap) {
|
||||
$this->codeMap = [];
|
||||
}
|
||||
|
||||
if (!$code) {
|
||||
$code = Str::getClassId($className);
|
||||
}
|
||||
|
||||
if ($code == 'viewBag' && $className != 'Cms\Components\ViewBag') {
|
||||
throw new SystemException(sprintf(
|
||||
'The component code viewBag is reserved. Please use another code for the component class %s.',
|
||||
$className
|
||||
));
|
||||
}
|
||||
|
||||
$className = Str::normalizeClassName($className);
|
||||
$this->codeMap[$code] = $className;
|
||||
$this->classMap[$className] = $code;
|
||||
if ($plugin !== null) {
|
||||
$this->pluginMap[$className] = $plugin;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of registered components.
|
||||
* @return array Array keys are codes, values are class names.
|
||||
*/
|
||||
public function listComponents()
|
||||
{
|
||||
if ($this->codeMap === null) {
|
||||
$this->loadComponents();
|
||||
}
|
||||
|
||||
return $this->codeMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of all component detail definitions.
|
||||
* @return array Array keys are component codes, values are the details defined in the component.
|
||||
*/
|
||||
public function listComponentDetails()
|
||||
{
|
||||
if ($this->detailsCache !== null) {
|
||||
return $this->detailsCache;
|
||||
}
|
||||
|
||||
$details = [];
|
||||
foreach ($this->listComponents() as $componentAlias => $componentClass) {
|
||||
$details[$componentAlias] = $this->makeComponent($componentClass)->componentDetails();
|
||||
}
|
||||
|
||||
return $this->detailsCache = $details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a class name from a component code
|
||||
* Normalizes a class name or converts an code to it's class name.
|
||||
* @return string The class name resolved, or null.
|
||||
*/
|
||||
public function resolve($name)
|
||||
{
|
||||
$codes = $this->listComponents();
|
||||
|
||||
if (isset($codes[$name])) {
|
||||
return $codes[$name];
|
||||
}
|
||||
|
||||
$name = Str::normalizeClassName($name);
|
||||
if (isset($this->classMap[$name])) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks to see if a component has been registered.
|
||||
* @param string $name A component class name or code.
|
||||
* @return bool Returns true if the component is registered, otherwise false.
|
||||
*/
|
||||
public function hasComponent($name)
|
||||
{
|
||||
$className = $this->resolve($name);
|
||||
if (!$className) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isset($this->classMap[$className]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a component object with properties set.
|
||||
*
|
||||
* @param string $name A component class name or code.
|
||||
* @param CmsObject $cmsObject The Cms object that spawned this component.
|
||||
* @param array $properties The properties set by the Page or Layout.
|
||||
* @param bool $isSoftComponent Defines if this is a soft component.
|
||||
*
|
||||
* @return ComponentBase The component object.
|
||||
* @throws SystemException If the (hard) component cannot be found or is not registered.
|
||||
*/
|
||||
public function makeComponent($name, $cmsObject = null, $properties = [], $isSoftComponent = false)
|
||||
{
|
||||
$className = $this->resolve(ltrim($name, '@'));
|
||||
|
||||
if (!$className && !$isSoftComponent) {
|
||||
throw new SystemException(sprintf(
|
||||
'Class name is not registered for the component "%s". Check the component plugin.',
|
||||
$name
|
||||
));
|
||||
}
|
||||
|
||||
if (!class_exists($className) && !$isSoftComponent) {
|
||||
throw new SystemException(sprintf(
|
||||
'Component class not found "%s". Check the component plugin.',
|
||||
$className
|
||||
));
|
||||
}
|
||||
|
||||
if (class_exists($className)) {
|
||||
$component = App::make($className, ['cmsObject' => $cmsObject, 'properties' => $properties]);
|
||||
$component->name = $name;
|
||||
|
||||
return $component;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a parent plugin for a specific component object.
|
||||
* @param mixed $component A component to find the plugin for.
|
||||
* @return mixed Returns the plugin object or null.
|
||||
*/
|
||||
public function findComponentPlugin($component)
|
||||
{
|
||||
$className = Str::normalizeClassName(get_class($component));
|
||||
if (isset($this->pluginMap[$className])) {
|
||||
return $this->pluginMap[$className];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
262
modules/cms/classes/ComponentPartial.php
Normal file
262
modules/cms/classes/ComponentPartial.php
Normal file
@@ -0,0 +1,262 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use File;
|
||||
use Lang;
|
||||
use Cms\Contracts\CmsObject as CmsObjectContract;
|
||||
use Cms\Helpers\File as FileHelper;
|
||||
use Winter\Storm\Extension\Extendable;
|
||||
use ApplicationException;
|
||||
|
||||
/**
|
||||
* The CMS component partial class. These objects are read-only.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class ComponentPartial extends Extendable implements CmsObjectContract
|
||||
{
|
||||
/**
|
||||
* @var \Cms\Classes\ComponentBase A reference to the CMS component containing the object.
|
||||
*/
|
||||
protected $component;
|
||||
|
||||
/**
|
||||
* @var string The component partial file name.
|
||||
*/
|
||||
public $fileName;
|
||||
|
||||
/**
|
||||
* @var string Last modified time.
|
||||
*/
|
||||
public $mtime;
|
||||
|
||||
/**
|
||||
* @var string Partial content.
|
||||
*/
|
||||
public $content;
|
||||
|
||||
/**
|
||||
* @var int The maximum allowed path nesting level. The default value is 2,
|
||||
* meaning that files can only exist in the root directory, or in a
|
||||
* subdirectory. Set to null if any level is allowed.
|
||||
*/
|
||||
protected $maxNesting = 2;
|
||||
|
||||
/**
|
||||
* @var array Allowable file extensions.
|
||||
*/
|
||||
protected $allowedExtensions = ['htm'];
|
||||
|
||||
/**
|
||||
* @var string Default file extension.
|
||||
*/
|
||||
protected $defaultExtension = 'htm';
|
||||
|
||||
/**
|
||||
* Creates an instance of the object and associates it with a CMS component.
|
||||
* @param \Cms\Classes\ComponentBase $component Specifies the component the object belongs to.
|
||||
*/
|
||||
public function __construct(ComponentBase $component)
|
||||
{
|
||||
$this->component = $component;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the object from a file.
|
||||
* This method is used in the CMS back-end. It doesn't use any caching.
|
||||
* @param \Cms\Classes\ComponentBase $component Specifies the component the object belongs to.
|
||||
* @param string $fileName Specifies the file name, with the extension.
|
||||
* The file name can contain only alphanumeric symbols, dashes and dots.
|
||||
* @return mixed Returns a CMS object instance or null if the object wasn't found.
|
||||
*/
|
||||
public static function load($component, $fileName)
|
||||
{
|
||||
return (new static($component))->find($fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* There is not much point caching a component partial, so this behavior
|
||||
* reverts to a regular load call.
|
||||
* @param \Cms\Classes\ComponentBase $component
|
||||
* @param string $fileName
|
||||
* @return mixed
|
||||
*/
|
||||
public static function loadCached($component, $fileName)
|
||||
{
|
||||
return static::load($component, $fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a partial override exists in the supplied theme and returns it.
|
||||
* Since the beginning of time, Winter inconsistently checked for overrides
|
||||
* using the component alias exactly, resulting in a folder with uppercase
|
||||
* characters, subsequently this method checks for both variants.
|
||||
*
|
||||
* @param \Cms\Classes\Theme $theme
|
||||
* @param \Cms\Classes\ComponentBase $component
|
||||
* @param string $fileName
|
||||
* @return mixed
|
||||
*/
|
||||
public static function loadOverrideCached($theme, $component, $fileName)
|
||||
{
|
||||
$partial = Partial::loadCached($theme, strtolower($component->alias) . '/' . $fileName);
|
||||
|
||||
if ($partial === null) {
|
||||
$partial = Partial::loadCached($theme, $component->alias . '/' . $fileName);
|
||||
}
|
||||
|
||||
return $partial;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single template by its file name.
|
||||
*
|
||||
* @param string $fileName
|
||||
* @return mixed|static
|
||||
*/
|
||||
public function find($fileName)
|
||||
{
|
||||
$fileName = $this->validateFileName($fileName);
|
||||
|
||||
$filePath = $this->getFilePath($fileName);
|
||||
|
||||
if (!File::isFile($filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (($content = @File::get($filePath)) === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->fileName = $fileName;
|
||||
$this->mtime = File::lastModified($filePath);
|
||||
$this->content = $content;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the specific component contains a matching partial.
|
||||
* @param \Cms\Classes\ComponentBase $component Specifies a component the file belongs to.
|
||||
* @param string $fileName Specifies the file name to check.
|
||||
* @return bool
|
||||
*/
|
||||
public static function check(ComponentBase $component, $fileName)
|
||||
{
|
||||
$partial = new static($component);
|
||||
$filePath = $partial->getFilePath($fileName);
|
||||
if (!strlen(File::extension($filePath))) {
|
||||
$filePath .= '.'.$partial->getDefaultExtension();
|
||||
}
|
||||
|
||||
return File::isFile($filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the supplied file name for validity.
|
||||
* @param string $fileName
|
||||
* @return string
|
||||
*/
|
||||
protected function validateFileName($fileName)
|
||||
{
|
||||
if (!FileHelper::validatePath($fileName, $this->maxNesting)) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.cms_object.invalid_file', [
|
||||
'name' => $fileName
|
||||
]));
|
||||
}
|
||||
|
||||
if (!strlen(File::extension($fileName))) {
|
||||
$fileName .= '.'.$this->defaultExtension;
|
||||
}
|
||||
|
||||
return $fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file content.
|
||||
* @return string
|
||||
*/
|
||||
public function getContent()
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Twig content string.
|
||||
* @return string
|
||||
*/
|
||||
public function getTwigContent()
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the key used by the Twig cache.
|
||||
* @return string
|
||||
*/
|
||||
public function getTwigCacheKey()
|
||||
{
|
||||
return $this->getFilePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file name.
|
||||
* @return string
|
||||
*/
|
||||
public function getFileName()
|
||||
{
|
||||
return $this->fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default extension used by this template.
|
||||
* @return string
|
||||
*/
|
||||
public function getDefaultExtension()
|
||||
{
|
||||
return $this->defaultExtension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file name without the extension.
|
||||
* @return string
|
||||
*/
|
||||
public function getBaseFileName()
|
||||
{
|
||||
$pos = strrpos($this->fileName, '.');
|
||||
if ($pos === false) {
|
||||
return $this->fileName;
|
||||
}
|
||||
|
||||
return substr($this->fileName, 0, $pos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute file path.
|
||||
* @param string $fileName Specifies the file name to return the path to.
|
||||
* @return string
|
||||
*/
|
||||
public function getFilePath($fileName = null)
|
||||
{
|
||||
if ($fileName === null) {
|
||||
$fileName = $this->fileName;
|
||||
}
|
||||
|
||||
$component = $this->component;
|
||||
$path = $component->getPath().'/'.$fileName;
|
||||
|
||||
/*
|
||||
* Check the shared "/partials" directory for the partial
|
||||
*/
|
||||
if (!File::isFile($path)) {
|
||||
$sharedDir = dirname($component->getPath()).'/partials';
|
||||
$sharedPath = $sharedDir.'/'.$fileName;
|
||||
if (File::isFile($sharedPath)) {
|
||||
return $sharedPath;
|
||||
}
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
73
modules/cms/classes/Content.php
Normal file
73
modules/cms/classes/Content.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use File;
|
||||
use Markdown;
|
||||
|
||||
/**
|
||||
* The CMS content file class.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class Content extends CmsCompoundObject
|
||||
{
|
||||
/**
|
||||
* @var string The container name associated with the model, eg: pages.
|
||||
*/
|
||||
protected $dirName = 'content';
|
||||
|
||||
/**
|
||||
* @var array Allowable file extensions.
|
||||
*/
|
||||
protected $allowedExtensions = ['htm', 'txt', 'md'];
|
||||
|
||||
/**
|
||||
* @var array List of attribute names which are not considered "settings".
|
||||
*/
|
||||
protected $purgeable = ['parsedMarkup'];
|
||||
|
||||
/**
|
||||
* Initializes the object properties from the cached data. The extra data
|
||||
* set here becomes available as attributes set on the model after fetch.
|
||||
* @param array $item The cached data array.
|
||||
*/
|
||||
public static function initCacheItem(&$item)
|
||||
{
|
||||
$item['parsedMarkup'] = (new static($item))->parseMarkup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a default value for parsedMarkup attribute.
|
||||
* @return string
|
||||
*/
|
||||
public function getParsedMarkupAttribute()
|
||||
{
|
||||
if (array_key_exists('parsedMarkup', $this->attributes)) {
|
||||
return $this->attributes['parsedMarkup'];
|
||||
}
|
||||
|
||||
return $this->attributes['parsedMarkup'] = $this->parseMarkup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the content markup according to the file type.
|
||||
* @return string
|
||||
*/
|
||||
public function parseMarkup()
|
||||
{
|
||||
$extension = strtolower(File::extension($this->fileName));
|
||||
|
||||
switch ($extension) {
|
||||
case 'txt':
|
||||
$result = htmlspecialchars($this->markup);
|
||||
break;
|
||||
case 'md':
|
||||
$result = Markdown::parse($this->markup);
|
||||
break;
|
||||
default:
|
||||
$result = $this->markup;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
1650
modules/cms/classes/Controller.php
Normal file
1650
modules/cms/classes/Controller.php
Normal file
File diff suppressed because it is too large
Load Diff
51
modules/cms/classes/Layout.php
Normal file
51
modules/cms/classes/Layout.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
/**
|
||||
* The CMS layout class.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class Layout extends CmsCompoundObject
|
||||
{
|
||||
/**
|
||||
* Fallback layout name.
|
||||
*/
|
||||
const FALLBACK_FILE_NAME = 'fallback';
|
||||
|
||||
/**
|
||||
* @var string The container name associated with the model, eg: pages.
|
||||
*/
|
||||
protected $dirName = 'layouts';
|
||||
|
||||
/**
|
||||
* Initializes the fallback layout.
|
||||
* @param \Cms\Classes\Theme $theme Specifies a theme the file belongs to.
|
||||
* @return \Cms\Classes\Layout
|
||||
*/
|
||||
public static function initFallback($theme)
|
||||
{
|
||||
$obj = self::inTheme($theme);
|
||||
$obj->markup = '{% page %}';
|
||||
$obj->fileName = self::FALLBACK_FILE_NAME;
|
||||
return $obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the layout is a fallback layout
|
||||
* @return boolean
|
||||
*/
|
||||
public function isFallBack()
|
||||
{
|
||||
return $this->fileName === self::FALLBACK_FILE_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns name of a PHP class to us a parent for the PHP class created for the object's PHP section.
|
||||
* @return mixed Returns the class name or null.
|
||||
*/
|
||||
public function getCodeClassParent()
|
||||
{
|
||||
return LayoutCode::class;
|
||||
}
|
||||
}
|
||||
18
modules/cms/classes/LayoutCode.php
Normal file
18
modules/cms/classes/LayoutCode.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
/**
|
||||
* Parent class for PHP classes created for layout PHP sections.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class LayoutCode extends CodeBase
|
||||
{
|
||||
/**
|
||||
* This event is triggered after the layout components are executed,
|
||||
* but before the page's onStart event.
|
||||
*/
|
||||
public function onBeforePageStart()
|
||||
{
|
||||
}
|
||||
}
|
||||
23
modules/cms/classes/MediaLibrary.php
Normal file
23
modules/cms/classes/MediaLibrary.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use System\Classes\MediaLibrary as SystemMediaLibrary;
|
||||
|
||||
/**
|
||||
* Provides abstraction level for the Media Library operations.
|
||||
* Implements the library caching features and security checks.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
* @deprecated Use System\Classes\MediaLibrary. Remove if year >= 2020.
|
||||
*/
|
||||
class MediaLibrary extends SystemMediaLibrary
|
||||
{
|
||||
/**
|
||||
* Initialize this singleton.
|
||||
*/
|
||||
protected function init()
|
||||
{
|
||||
traceLog('Class ' . __CLASS__ . ' has been deprecated, use ' . SystemMediaLibrary::class . ' instead.');
|
||||
parent::init();
|
||||
}
|
||||
}
|
||||
19
modules/cms/classes/MediaLibraryItem.php
Normal file
19
modules/cms/classes/MediaLibraryItem.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use System\Classes\MediaLibraryItem as SystemMediaLibraryItem;
|
||||
|
||||
/**
|
||||
* Represents a file or folder in the Media Library.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
* @deprecated Use System\Classes\MediaLibraryItem. Remove if year >= 2020.
|
||||
*/
|
||||
class MediaLibraryItem extends SystemMediaLibraryItem
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
traceLog('Class Cms\Classes\MediaLibraryItem has been deprecated, use ' . SystemMediaLibraryItem::class . ' instead.');
|
||||
parent::__construct(...func_get_args());
|
||||
}
|
||||
}
|
||||
111
modules/cms/classes/MediaViewHelper.php
Normal file
111
modules/cms/classes/MediaViewHelper.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use ApplicationException;
|
||||
|
||||
/**
|
||||
* Helper class for processing video and audio tags inserted by the Media Manager.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class MediaViewHelper
|
||||
{
|
||||
use \Winter\Storm\Support\Traits\Singleton;
|
||||
|
||||
protected $playerPartialFlags = [];
|
||||
|
||||
/**
|
||||
* Replaces audio and video tags inserted by the Media Manager with players markup.
|
||||
* @param string $html Specifies the HTML string to process.
|
||||
* @return string Returns the processed HTML string.
|
||||
*/
|
||||
public function processHtml($html)
|
||||
{
|
||||
if (!is_string($html)) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
$mediaTags = $this->extractMediaTags($html);
|
||||
foreach ($mediaTags as $tagInfo) {
|
||||
$pattern = preg_quote($tagInfo['declaration']);
|
||||
$generatedMarkup = $this->generateMediaTagMarkup($tagInfo['type'], $tagInfo['src']);
|
||||
$html = mb_ereg_replace($pattern, $generatedMarkup, $html);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
protected function extractMediaTags($html)
|
||||
{
|
||||
$result = [];
|
||||
$matches = [];
|
||||
|
||||
$tagDefinitions = [
|
||||
'audio' => '/data\-audio\s*=\s*"([^"]+)"/',
|
||||
'video' => '/data\-video\s*=\s*"([^"]+)"/'
|
||||
];
|
||||
|
||||
if (preg_match_all('/\<figure\s+[^\>]+\>[^\<]*\<\/figure\>/i', $html, $matches)) {
|
||||
foreach ($matches[0] as $mediaDeclaration) {
|
||||
foreach ($tagDefinitions as $type => $pattern) {
|
||||
$nameMatch = [];
|
||||
if (preg_match($pattern, $mediaDeclaration, $nameMatch)) {
|
||||
$result[] = [
|
||||
'declaration' => $mediaDeclaration,
|
||||
'type' => $type,
|
||||
'src' => $nameMatch[1]
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected function generateMediaTagMarkup($type, $src)
|
||||
{
|
||||
$partialName = $type == 'audio' ? 'oc-audio-player' : 'oc-video-player';
|
||||
|
||||
if ($this->playerPartialExists($partialName)) {
|
||||
return Controller::getController()->renderPartial($partialName, ['src' => $src]);
|
||||
}
|
||||
|
||||
$partialName = $type == 'audio' ? 'wn-audio-player' : 'wn-video-player';
|
||||
|
||||
if ($this->playerPartialExists($partialName)) {
|
||||
return Controller::getController()->renderPartial($partialName, ['src' => $src]);
|
||||
}
|
||||
|
||||
return $this->getDefaultPlayerMarkup($type, $src);
|
||||
}
|
||||
|
||||
protected function playerPartialExists($name)
|
||||
{
|
||||
if (array_key_exists($name, $this->playerPartialFlags)) {
|
||||
return $this->playerPartialFlags[$name];
|
||||
}
|
||||
|
||||
$controller = Controller::getController();
|
||||
if (!$controller) {
|
||||
throw new ApplicationException('Media tags can only be processed for front-end requests.');
|
||||
}
|
||||
|
||||
$partial = Partial::loadCached($controller->getTheme(), $name);
|
||||
|
||||
return $this->playerPartialFlags[$name] = !!$partial;
|
||||
}
|
||||
|
||||
protected function getDefaultPlayerMarkup($type, $src)
|
||||
{
|
||||
switch ($type) {
|
||||
case 'video':
|
||||
return '<video src="'.e($src).'" controls preload="metadata"></video>';
|
||||
break;
|
||||
|
||||
case 'audio':
|
||||
return '<audio src="'.e($src).'" controls preload="metadata"></audio>';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
85
modules/cms/classes/Meta.php
Normal file
85
modules/cms/classes/Meta.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use Yaml;
|
||||
|
||||
/**
|
||||
* The CMS meta file class, used for interacting with YAML files within the Halcyon datasources
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Luke Towers
|
||||
*/
|
||||
class Meta extends CmsObject
|
||||
{
|
||||
/**
|
||||
* @var string The container name associated with the model, eg: pages.
|
||||
*/
|
||||
protected $dirName = 'meta';
|
||||
|
||||
/**
|
||||
* @var array Cache store used by parseContent method.
|
||||
*/
|
||||
protected $contentDataCache;
|
||||
|
||||
/**
|
||||
* @var array Allowable file extensions.
|
||||
*/
|
||||
protected $allowedExtensions = ['yaml'];
|
||||
|
||||
/**
|
||||
* @var string Default file extension.
|
||||
*/
|
||||
protected $defaultExtension = 'yaml';
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function __construct(array $attributes = [])
|
||||
{
|
||||
parent::__construct($attributes);
|
||||
|
||||
// Bind data processing to model events
|
||||
$this->bindEvent('model.beforeSave', function () {
|
||||
$this->content = $this->renderContent();
|
||||
});
|
||||
$this->bindEvent('model.afterFetch', function () {
|
||||
$this->attributes = array_merge($this->attributes, $this->parseContent());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the content attribute to an array of menu data.
|
||||
* @return array|null
|
||||
*/
|
||||
protected function parseContent()
|
||||
{
|
||||
if ($this->contentDataCache !== null) {
|
||||
return $this->contentDataCache;
|
||||
}
|
||||
|
||||
$parsedData = Yaml::parse($this->content);
|
||||
|
||||
if (!is_array($parsedData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->contentDataCache = $parsedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the meta data as a content string in YAML format.
|
||||
* @return string
|
||||
*/
|
||||
protected function renderContent()
|
||||
{
|
||||
return Yaml::render($this->settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile the content for this CMS object, used by the theme logger.
|
||||
* @return string
|
||||
*/
|
||||
public function toCompiled()
|
||||
{
|
||||
return $this->renderContent();
|
||||
}
|
||||
}
|
||||
12
modules/cms/classes/ObjectMemoryCache.php
Normal file
12
modules/cms/classes/ObjectMemoryCache.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
/**
|
||||
* Provides a simple request-level cache for CMS objects.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class ObjectMemoryCache
|
||||
{
|
||||
public static $cache = [];
|
||||
}
|
||||
238
modules/cms/classes/Page.php
Normal file
238
modules/cms/classes/Page.php
Normal file
@@ -0,0 +1,238 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use Lang;
|
||||
use BackendAuth;
|
||||
use ApplicationException;
|
||||
use Winter\Storm\Filesystem\Definitions as FileDefinitions;
|
||||
|
||||
/**
|
||||
* The CMS page class.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class Page extends CmsCompoundObject
|
||||
{
|
||||
/**
|
||||
* @var string The container name associated with the model, eg: pages.
|
||||
*/
|
||||
protected $dirName = 'pages';
|
||||
|
||||
/**
|
||||
* @var array The attributes that are mass assignable.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'url',
|
||||
'layout',
|
||||
'title',
|
||||
'description',
|
||||
'is_hidden',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
'markup',
|
||||
'settings',
|
||||
'code'
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array The API bag allows the API handler code to bind arbitrary
|
||||
* data to the page object.
|
||||
*/
|
||||
public $apiBag = [];
|
||||
|
||||
/**
|
||||
* @var array The rules to be applied to the data.
|
||||
*/
|
||||
public $rules = [
|
||||
'title' => 'required',
|
||||
'url' => ['required', 'regex:/^\/[a-z0-9\/\:_\-\*\[\]\+\?\|\.\^\\\$]*$/i']
|
||||
];
|
||||
|
||||
/**
|
||||
* Creates an instance of the object and associates it with a CMS theme.
|
||||
* @param array $attributes
|
||||
*/
|
||||
public function __construct(array $attributes = [])
|
||||
{
|
||||
parent::__construct($attributes);
|
||||
|
||||
$this->customMessages = [
|
||||
'url.regex' => 'cms::lang.page.invalid_url',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns name of a PHP class to us a parent for the PHP class created for the object's PHP section.
|
||||
* @return mixed Returns the class name or null.
|
||||
*/
|
||||
public function getCodeClassParent()
|
||||
{
|
||||
return PageCode::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of layouts available in the theme.
|
||||
* This method is used by the form widget.
|
||||
* @return array Returns an array of strings.
|
||||
*/
|
||||
public function getLayoutOptions()
|
||||
{
|
||||
if (!($theme = Theme::getEditTheme())) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.theme.edit.not_found'));
|
||||
}
|
||||
|
||||
$layouts = Layout::listInTheme($theme, true);
|
||||
$result = [];
|
||||
$result[''] = Lang::get('cms::lang.page.no_layout');
|
||||
|
||||
foreach ($layouts as $layout) {
|
||||
$baseName = $layout->getBaseFileName();
|
||||
|
||||
if (FileDefinitions::isPathIgnored($baseName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[$baseName] = strlen($layout->name) ? $layout->name : $baseName;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper that returns a nicer list of pages for use in dropdowns.
|
||||
* @return array
|
||||
*/
|
||||
public static function getNameList()
|
||||
{
|
||||
$result = [];
|
||||
$pages = self::sortBy('baseFileName')->all();
|
||||
foreach ($pages as $page) {
|
||||
$result[$page->baseFileName] = $page->title . ' (' . $page->baseFileName . ')';
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper that makes a URL for a page in the active theme.
|
||||
* @param mixed $page Specifies the Cms Page file name.
|
||||
* @param array $params Route parameters to consider in the URL.
|
||||
* @return string|null
|
||||
*/
|
||||
public static function url($page, array $params = [])
|
||||
{
|
||||
/*
|
||||
* Reuse existing controller or create a new one,
|
||||
* assuming that the method is called not during the front-end
|
||||
* request processing.
|
||||
*/
|
||||
$controller = Controller::getController() ?: new Controller;
|
||||
|
||||
return $controller->pageUrl($page, $params, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for the pages.menuitem.getTypeInfo event.
|
||||
* Returns a menu item type information. The type information is returned as array
|
||||
* with the following elements:
|
||||
* - references - a list of the item type reference options. The options are returned in the
|
||||
* ["key"] => "title" format for options that don't have sub-options, and in the format
|
||||
* ["key"] => ["title"=>"Option title", "items"=>[...]] for options that have sub-options. Optional,
|
||||
* required only if the menu item type requires references.
|
||||
* - nesting - Boolean value indicating whether the item type supports nested items. Optional,
|
||||
* false if omitted.
|
||||
* - dynamicItems - Boolean value indicating whether the item type could generate new menu items.
|
||||
* Optional, false if omitted.
|
||||
* - cmsPages - a list of CMS pages (objects of the Cms\Classes\Page class), if the item type requires
|
||||
* a CMS page reference to resolve the item URL.
|
||||
* @param string $type Specifies the menu item type
|
||||
* @return array Returns an array
|
||||
*/
|
||||
public static function getMenuTypeInfo(string $type)
|
||||
{
|
||||
$result = [];
|
||||
|
||||
if ($type === 'cms-page') {
|
||||
$theme = Theme::getActiveTheme();
|
||||
$pages = self::listInTheme($theme, true);
|
||||
$references = [];
|
||||
|
||||
foreach ($pages as $page) {
|
||||
$references[$page->getBaseFileName()] = $page->title . ' [' . $page->getBaseFileName() . ']';
|
||||
}
|
||||
|
||||
$result = [
|
||||
'references' => $references,
|
||||
'nesting' => false,
|
||||
'dynamicItems' => false
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for the pages.menuitem.resolveItem event.
|
||||
* Returns information about a menu item. The result is an array
|
||||
* with the following keys:
|
||||
* - url - the menu item URL. Not required for menu item types that return all available records.
|
||||
* The URL should be returned relative to the website root and include the subdirectory, if any.
|
||||
* Use the Url::to() helper to generate the URLs.
|
||||
* - isActive - determines whether the menu item is active. Not required for menu item types that
|
||||
* return all available records.
|
||||
* - items - an array of arrays with the same keys (url, isActive, items) + the title key.
|
||||
* The items array should be added only if the $item's $nesting property value is TRUE.
|
||||
*
|
||||
* @param \Winter\Sitemap\Classes\DefinitionItem|\Winter\Pages\Classes\MenuItem $item Specifies the menu item.
|
||||
*/
|
||||
public static function resolveMenuItem(object $item, string $url, Theme $theme, bool $routePersistence = false): ?array
|
||||
{
|
||||
$result = null;
|
||||
|
||||
if ($item->type === 'cms-page') {
|
||||
if (!$item->reference) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$page = self::loadCached($theme, $item->reference);
|
||||
|
||||
// Remove hidden CMS pages from menus when backend user is logged out
|
||||
if ($page && $page->is_hidden && !BackendAuth::getUser()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$controller = Controller::getController() ?: new Controller;
|
||||
$pageUrl = $controller->pageUrl($item->reference, [], $routePersistence);
|
||||
|
||||
$result = [];
|
||||
$result['url'] = $pageUrl;
|
||||
$result['isActive'] = rtrim($pageUrl, '/') === rtrim($url, '/');
|
||||
$result['mtime'] = $page ? $page->mtime : null;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for the backend.richeditor.getTypeInfo event.
|
||||
* Returns a menu item type information. The type information is returned as array
|
||||
* @param string $type Specifies the page link type
|
||||
* @return array
|
||||
*/
|
||||
public static function getRichEditorTypeInfo(string $type)
|
||||
{
|
||||
$result = [];
|
||||
|
||||
if ($type === 'cms-page') {
|
||||
$theme = Theme::getActiveTheme();
|
||||
$pages = self::listInTheme($theme, true);
|
||||
|
||||
foreach ($pages as $page) {
|
||||
$url = self::url($page->getBaseFileName());
|
||||
$result[$url] = $page->title;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
11
modules/cms/classes/PageCode.php
Normal file
11
modules/cms/classes/PageCode.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
/**
|
||||
* Parent class for PHP classes created for page PHP sections.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class PageCode extends CodeBase
|
||||
{
|
||||
}
|
||||
24
modules/cms/classes/Partial.php
Normal file
24
modules/cms/classes/Partial.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
/**
|
||||
* The CMS partial class.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class Partial extends CmsCompoundObject
|
||||
{
|
||||
/**
|
||||
* @var string The container name associated with the model, eg: pages.
|
||||
*/
|
||||
protected $dirName = 'partials';
|
||||
|
||||
/**
|
||||
* Returns name of a PHP class to us a parent for the PHP class created for the object's PHP section.
|
||||
* @return string Returns the class name.
|
||||
*/
|
||||
public function getCodeClassParent()
|
||||
{
|
||||
return PartialCode::class;
|
||||
}
|
||||
}
|
||||
11
modules/cms/classes/PartialCode.php
Normal file
11
modules/cms/classes/PartialCode.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
/**
|
||||
* Parent class for PHP classes created for partial PHP sections.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class PartialCode extends CodeBase
|
||||
{
|
||||
}
|
||||
93
modules/cms/classes/PartialStack.php
Normal file
93
modules/cms/classes/PartialStack.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
/**
|
||||
* Manager class for stacking nested partials and keeping track
|
||||
* of their components. Partial "objects" store the components
|
||||
* used by that partial for deferred retrieval.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class PartialStack
|
||||
{
|
||||
/**
|
||||
* @var array The current partial "object" being rendered.
|
||||
*/
|
||||
public $activePartial;
|
||||
|
||||
/**
|
||||
* @var array Collection of previously rendered partial "objects".
|
||||
*/
|
||||
protected $partialStack = [];
|
||||
|
||||
/**
|
||||
* Partial entry point, appends a new partial to the stack.
|
||||
*/
|
||||
public function stackPartial()
|
||||
{
|
||||
if ($this->activePartial !== null) {
|
||||
array_unshift($this->partialStack, $this->activePartial);
|
||||
}
|
||||
|
||||
$this->activePartial = [
|
||||
'components' => []
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial exit point, removes the active partial from the stack.
|
||||
*/
|
||||
public function unstackPartial()
|
||||
{
|
||||
$this->activePartial = array_shift($this->partialStack);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a component to the active partial stack.
|
||||
*/
|
||||
public function addComponent($alias, $componentObj)
|
||||
{
|
||||
array_push($this->activePartial['components'], [
|
||||
'name' => $alias,
|
||||
'obj' => $componentObj
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a component by its alias from the partial stack.
|
||||
*/
|
||||
public function getComponent($name)
|
||||
{
|
||||
if (!$this->activePartial) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$component = $this->findComponentFromStack($name, $this->activePartial);
|
||||
if ($component !== null) {
|
||||
return $component;
|
||||
}
|
||||
|
||||
foreach ($this->partialStack as $stack) {
|
||||
$component = $this->findComponentFromStack($name, $stack);
|
||||
if ($component !== null) {
|
||||
return $component;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates a component by its alias from the supplied stack.
|
||||
*/
|
||||
protected function findComponentFromStack($name, $stack)
|
||||
{
|
||||
foreach ($stack['components'] as $componentInfo) {
|
||||
if ($componentInfo['name'] == $name) {
|
||||
return $componentInfo['obj'];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
358
modules/cms/classes/Router.php
Normal file
358
modules/cms/classes/Router.php
Normal file
@@ -0,0 +1,358 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use Lang;
|
||||
use File;
|
||||
use Cache;
|
||||
use Config;
|
||||
use Event;
|
||||
use Winter\Storm\Router\Router as StormRouter;
|
||||
use Winter\Storm\Router\Helper as RouterHelper;
|
||||
|
||||
/**
|
||||
* The router parses page URL patterns and finds pages by URLs.
|
||||
*
|
||||
* The page URL format is explained below.
|
||||
* <pre>/blog/post/:post_id</pre>
|
||||
* Name of parameters should be compatible with PHP variable names. To make a parameter optional
|
||||
* add the question mark after its name:
|
||||
* <pre>/blog/post/:post_id?</pre>
|
||||
* By default parameters in the middle of the URL are required, for example:
|
||||
* <pre>/blog/:post_id?/comments - although the :post_id parameter is marked as optional,
|
||||
* it will be processed as required.</pre>
|
||||
* Optional parameters can have default values which are used as fallback values in case if the real
|
||||
* parameter value is not presented in the URL. Default values cannot contain the pipe symbols and question marks.
|
||||
* Specify the default value after the question mark:
|
||||
* <pre>/blog/category/:category_id?10 - The category_id parameter would be 10 for this URL: /blog/category</pre>
|
||||
* You can also add regular expression validation to parameters. To add a validation expression
|
||||
* add the pipe symbol after the parameter name (or the question mark) and specify the expression.
|
||||
* The forward slash symbol is not allowed in the expressions. Examples:
|
||||
* <pre>/blog/:post_id|^[0-9]+$/comments - this will match /blog/post/10/comments
|
||||
* /blog/:post_id|^[0-9]+$ - this will match /blog/post/3
|
||||
* /blog/:post_name?|^[a-z0-9\-]+$ - this will match /blog/my-blog-post</pre>
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class Router
|
||||
{
|
||||
/**
|
||||
* @var \Cms\Classes\Theme A reference to the CMS theme containing the object.
|
||||
*/
|
||||
protected $theme;
|
||||
|
||||
/**
|
||||
* @var string The last URL to be looked up using findByUrl().
|
||||
*/
|
||||
protected $url;
|
||||
|
||||
/**
|
||||
* @var array A list of parameters names and values extracted from the URL pattern and URL string.
|
||||
*/
|
||||
protected $parameters = [];
|
||||
|
||||
/**
|
||||
* @var array Contains the URL map - the list of page file names and corresponding URL patterns.
|
||||
*/
|
||||
protected $urlMap = [];
|
||||
|
||||
/**
|
||||
* Winter\Storm\Router\Router Router object with routes preloaded.
|
||||
*/
|
||||
protected $routerObj;
|
||||
|
||||
/**
|
||||
* Creates the router instance.
|
||||
* @param \Cms\Classes\Theme $theme Specifies the theme being processed.
|
||||
*/
|
||||
public function __construct(Theme $theme)
|
||||
{
|
||||
$this->theme = $theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a page by its URL. Returns the page object and sets the $parameters property.
|
||||
* @param string $url The requested URL string.
|
||||
* @return \Cms\Classes\Page Returns \Cms\Classes\Page object or null if the page cannot be found.
|
||||
*/
|
||||
public function findByUrl($url)
|
||||
{
|
||||
$this->url = $url;
|
||||
$url = RouterHelper::normalizeUrl($url);
|
||||
|
||||
/**
|
||||
* @event cms.router.beforeRoute
|
||||
* Fires before the CMS Router handles a route
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* Event::listen('cms.router.beforeRoute', function ((string) $url, (\Cms\Classes\Router) $thisRouterInstance) {
|
||||
* return \Cms\Classes\Page::loadCached('trick-theme-code', 'page-file-name');
|
||||
* });
|
||||
*
|
||||
*/
|
||||
$apiResult = Event::fire('cms.router.beforeRoute', [$url, $this], true);
|
||||
if ($apiResult !== null) {
|
||||
return $apiResult;
|
||||
}
|
||||
|
||||
for ($pass = 1; $pass <= 2; $pass++) {
|
||||
$fileName = null;
|
||||
$urlList = [];
|
||||
|
||||
$cacheable = Config::get('cms.enableRoutesCache');
|
||||
if ($cacheable) {
|
||||
$fileName = $this->getCachedUrlFileName($url, $urlList);
|
||||
if (is_array($fileName)) {
|
||||
list($fileName, $this->parameters) = $fileName;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Find the page by URL and cache the route
|
||||
*/
|
||||
if (!$fileName) {
|
||||
$router = $this->getRouterObject();
|
||||
if ($router->match($url)) {
|
||||
$this->parameters = $router->getParameters();
|
||||
|
||||
$fileName = $router->matchedRoute();
|
||||
|
||||
if ($cacheable) {
|
||||
if (!$urlList || !is_array($urlList)) {
|
||||
$urlList = [];
|
||||
}
|
||||
|
||||
$urlList[$url] = !empty($this->parameters)
|
||||
? [$fileName, $this->parameters]
|
||||
: $fileName;
|
||||
|
||||
$key = $this->getUrlListCacheKey();
|
||||
$expiresAt = now()->addMinutes(Config::get('cms.urlCacheTtl', 1));
|
||||
Cache::put(
|
||||
$key,
|
||||
base64_encode(serialize($urlList)),
|
||||
$expiresAt
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Return the page
|
||||
*/
|
||||
if ($fileName) {
|
||||
if (($page = Page::loadCached($this->theme, $fileName)) === null) {
|
||||
/*
|
||||
* If the page was not found on the disk, clear the URL cache
|
||||
* and repeat the routing process.
|
||||
*/
|
||||
if ($pass == 1) {
|
||||
$this->clearCache();
|
||||
continue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a URL by it's page. Returns the URL route for linking to the page and uses the supplied
|
||||
* parameters in it's address.
|
||||
* @param string $fileName Page file name.
|
||||
* @param array $parameters Route parameters to consider in the URL.
|
||||
* @return string A built URL matching the page route.
|
||||
*/
|
||||
public function findByFile($fileName, $parameters = [])
|
||||
{
|
||||
if (!strlen(File::extension($fileName))) {
|
||||
$fileName .= '.htm';
|
||||
}
|
||||
|
||||
$router = $this->getRouterObject();
|
||||
return $router->url($fileName, $parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Autoloads the URL map only allowing a single execution.
|
||||
* @return array Returns the URL map.
|
||||
*/
|
||||
protected function getRouterObject()
|
||||
{
|
||||
if ($this->routerObj !== null) {
|
||||
return $this->routerObj;
|
||||
}
|
||||
|
||||
/*
|
||||
* Load up each route rule
|
||||
*/
|
||||
$router = new StormRouter();
|
||||
foreach ($this->getUrlMap() as $pageInfo) {
|
||||
$router->route($pageInfo['file'], $pageInfo['pattern']);
|
||||
}
|
||||
|
||||
/*
|
||||
* Sort all the rules
|
||||
*/
|
||||
$router->sortRules();
|
||||
|
||||
return $this->routerObj = $router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Autoloads the URL map only allowing a single execution.
|
||||
* @return array Returns the URL map.
|
||||
*/
|
||||
protected function getUrlMap()
|
||||
{
|
||||
if (!count($this->urlMap)) {
|
||||
$this->loadUrlMap();
|
||||
}
|
||||
|
||||
return $this->urlMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the URL map - a list of page file names and corresponding URL patterns.
|
||||
* The URL map can is cached. The clearUrlMap() method resets the cache. By default
|
||||
* the map is updated every time when a page is saved in the back-end, or
|
||||
* when the interval defined with the cms.urlCacheTtl expires.
|
||||
* @return boolean Returns true if the URL map was loaded from the cache. Otherwise returns false.
|
||||
*/
|
||||
protected function loadUrlMap()
|
||||
{
|
||||
$key = $this->getCacheKey('page-url-map');
|
||||
|
||||
$cacheable = Config::get('cms.enableRoutesCache');
|
||||
if ($cacheable) {
|
||||
$cached = Cache::get($key, false);
|
||||
}
|
||||
else {
|
||||
$cached = false;
|
||||
}
|
||||
|
||||
if (!$cached || ($unserialized = @unserialize(@base64_decode($cached))) === false) {
|
||||
/*
|
||||
* The item doesn't exist in the cache, create the map
|
||||
*/
|
||||
$pages = $this->theme->listPages();
|
||||
$map = [];
|
||||
foreach ($pages as $page) {
|
||||
if (!$page->url) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$map[] = ['file' => $page->getFileName(), 'pattern' => $page->url];
|
||||
}
|
||||
|
||||
$this->urlMap = $map;
|
||||
if ($cacheable) {
|
||||
$expiresAt = now()->addMinutes(Config::get('cms.urlCacheTtl', 1));
|
||||
Cache::put($key, base64_encode(serialize($map)), $expiresAt);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->urlMap = $unserialized;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the router cache.
|
||||
*/
|
||||
public function clearCache()
|
||||
{
|
||||
Cache::forget($this->getCacheKey('page-url-map'));
|
||||
Cache::forget($this->getCacheKey('cms-url-list'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current routing parameters.
|
||||
* @param array $parameters
|
||||
* @return array
|
||||
*/
|
||||
public function setParameters(array $parameters)
|
||||
{
|
||||
$this->parameters = $parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current routing parameters.
|
||||
* @return array
|
||||
*/
|
||||
public function getParameters()
|
||||
{
|
||||
return $this->parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last URL to be looked up.
|
||||
* @return string
|
||||
*/
|
||||
public function getUrl()
|
||||
{
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a routing parameter.
|
||||
* @param string $name
|
||||
* @param string|null $default
|
||||
* @return string|null
|
||||
*/
|
||||
public function getParameter($name, $default = null)
|
||||
{
|
||||
if (isset($this->parameters[$name]) && ($this->parameters[$name] === '0' || !empty($this->parameters[$name]))) {
|
||||
return $this->parameters[$name];
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the caching URL key depending on the theme.
|
||||
* @param string $keyName Specifies the base key name.
|
||||
* @return string Returns the theme-specific key name.
|
||||
*/
|
||||
protected function getCacheKey($keyName)
|
||||
{
|
||||
return md5($this->theme->getPath()).$keyName.Lang::getLocale();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cache key name for the URL list.
|
||||
* @return string
|
||||
*/
|
||||
protected function getUrlListCacheKey()
|
||||
{
|
||||
return $this->getCacheKey('cms-url-list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to load a page file name corresponding to a specified URL from the cache.
|
||||
* @param string $url Specifies the requested URL.
|
||||
* @param array &$urlList The URL list loaded from the cache
|
||||
* @return mixed Returns the page file name if the URL exists in the cache. Otherwise returns null.
|
||||
*/
|
||||
protected function getCachedUrlFileName($url, &$urlList)
|
||||
{
|
||||
$key = $this->getUrlListCacheKey();
|
||||
$urlList = Cache::get($key, false);
|
||||
|
||||
if ($urlList
|
||||
&& ($urlList = @unserialize(@base64_decode($urlList)))
|
||||
&& is_array($urlList)
|
||||
&& array_key_exists($url, $urlList)
|
||||
) {
|
||||
return $urlList[$url];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
735
modules/cms/classes/Theme.php
Normal file
735
modules/cms/classes/Theme.php
Normal file
@@ -0,0 +1,735 @@
|
||||
<?php
|
||||
|
||||
namespace Cms\Classes;
|
||||
|
||||
use Cms\Models\ThemeData;
|
||||
use DirectoryIterator;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Lang;
|
||||
use System\Models\Parameter;
|
||||
use Winter\Storm\Exception\ApplicationException;
|
||||
use Winter\Storm\Exception\SystemException;
|
||||
use Winter\Storm\Halcyon\Datasource\DatasourceInterface;
|
||||
use Winter\Storm\Halcyon\Datasource\DbDatasource;
|
||||
use Winter\Storm\Halcyon\Datasource\FileDatasource;
|
||||
use Winter\Storm\Support\Facades\Config;
|
||||
use Winter\Storm\Support\Facades\Event;
|
||||
use Winter\Storm\Support\Facades\File;
|
||||
use Winter\Storm\Support\Facades\Url;
|
||||
use Winter\Storm\Support\Facades\Yaml;
|
||||
use Winter\Storm\Support\Str;
|
||||
|
||||
/**
|
||||
* This class represents the CMS theme.
|
||||
* CMS theme is a directory that contains all CMS objects - pages, layouts, partials and asset files..
|
||||
* The theme parameters are specified in the theme.ini file in the theme root directory.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class Theme extends CmsObject
|
||||
{
|
||||
/**
|
||||
* @var string Specifies the theme directory name.
|
||||
*/
|
||||
protected $dirName;
|
||||
|
||||
/**
|
||||
* @var mixed Keeps the cached configuration file values.
|
||||
*/
|
||||
protected $configCache;
|
||||
|
||||
/**
|
||||
* @var mixed Active theme cache in memory
|
||||
*/
|
||||
protected static $activeThemeCache = false;
|
||||
|
||||
/**
|
||||
* @var mixed Edit theme cache in memory
|
||||
*/
|
||||
protected static $editThemeCache = false;
|
||||
|
||||
/**
|
||||
* @var array Allowable file extensions.
|
||||
*/
|
||||
protected $allowedExtensions = ['yaml'];
|
||||
|
||||
/**
|
||||
* @var string Default file extension.
|
||||
*/
|
||||
protected $defaultExtension = 'yaml';
|
||||
|
||||
const ACTIVE_KEY = 'cms::theme.active';
|
||||
const EDIT_KEY = 'cms::theme.edit';
|
||||
|
||||
/**
|
||||
* Loads the theme.
|
||||
*/
|
||||
public static function load($dirName, $file = null): ?static
|
||||
{
|
||||
$theme = new static;
|
||||
$theme->setDirName($dirName);
|
||||
$theme->registerHalcyonDatasource();
|
||||
if (App::runningInBackend()) {
|
||||
$theme->registerBackendLocalization();
|
||||
}
|
||||
|
||||
return $theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute theme path.
|
||||
*/
|
||||
public function getPath(?string $dirName = null): string
|
||||
{
|
||||
if (!$dirName) {
|
||||
$dirName = $this->getDirName();
|
||||
}
|
||||
|
||||
return themes_path($dirName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the theme directory name.
|
||||
* @throws ApplicationException if the directory name is invalid.
|
||||
*/
|
||||
public function setDirName(string $dirName): void
|
||||
{
|
||||
if (!static::isValidDirName($dirName)) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.theme.dir_name_invalid'));
|
||||
}
|
||||
|
||||
$this->dirName = $dirName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the theme directory name.
|
||||
*/
|
||||
public function getDirName(): string
|
||||
{
|
||||
return $this->dirName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the given directory name is valid.
|
||||
*/
|
||||
public static function isValidDirName(string $dirName): bool
|
||||
{
|
||||
return (bool) preg_match('/^[a-z0-9\_\-]+$/i', $dirName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for {{ theme.id }} twig vars
|
||||
* Returns a unique string for this theme.
|
||||
*/
|
||||
public function getId(): string
|
||||
{
|
||||
return snake_case(str_replace('/', '-', $this->getDirName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a theme with given directory name exists
|
||||
*/
|
||||
public static function exists(string $dirName): bool
|
||||
{
|
||||
$theme = static::load($dirName);
|
||||
$path = $theme->getPath();
|
||||
|
||||
return File::isDirectory($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of pages in the theme.
|
||||
* This method is used internally in the routing process and in the back-end UI.
|
||||
*/
|
||||
public function listPages(bool $skipCache = false): \Cms\Classes\CmsObjectCollection
|
||||
{
|
||||
return Page::listInTheme($this, $skipCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this theme is the chosen active theme.
|
||||
*/
|
||||
public function isActiveTheme(): bool
|
||||
{
|
||||
$activeTheme = self::getActiveTheme();
|
||||
|
||||
return $activeTheme && $activeTheme->getDirName() === $this->getDirName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active theme code.
|
||||
* By default the active theme is loaded from the cms.activeTheme parameter,
|
||||
* but this behavior can be overridden by the cms.theme.getActiveTheme event listener.
|
||||
* If the theme doesn't exist, returns null.
|
||||
*/
|
||||
public static function getActiveThemeCode(): string
|
||||
{
|
||||
/**
|
||||
* @event cms.theme.getActiveTheme
|
||||
* Overrides the active theme code.
|
||||
*
|
||||
* If a value is returned from this halting event, it will be used as the active
|
||||
* theme code. Example usage:
|
||||
*
|
||||
* Event::listen('cms.theme.getActiveTheme', function () {
|
||||
* return 'mytheme';
|
||||
* });
|
||||
*
|
||||
*/
|
||||
$apiResult = Event::fire('cms.theme.getActiveTheme', [], true);
|
||||
if ($apiResult !== null) {
|
||||
return $apiResult;
|
||||
}
|
||||
|
||||
// Load the active theme from the configuration
|
||||
$activeTheme = $configuredTheme = Config::get('cms.activeTheme');
|
||||
|
||||
// Attempt to load the active theme from the cache before checking the database
|
||||
try {
|
||||
$cached = Cache::get(self::ACTIVE_KEY, null);
|
||||
if (
|
||||
is_array($cached)
|
||||
// Check if the configured theme has changed
|
||||
&& $cached['config'] === $configuredTheme
|
||||
) {
|
||||
return $cached['active'];
|
||||
}
|
||||
} catch (Exception $ex) {
|
||||
// Cache failed
|
||||
}
|
||||
|
||||
// Check the database
|
||||
if (App::hasDatabase()) {
|
||||
try {
|
||||
$dbResult = Parameter::applyKey(self::ACTIVE_KEY)->value('value');
|
||||
} catch (Exception $ex) {
|
||||
$dbResult = null;
|
||||
}
|
||||
|
||||
if ($dbResult !== null && static::exists($dbResult)) {
|
||||
$activeTheme = $dbResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (!strlen($activeTheme)) {
|
||||
throw new SystemException(Lang::get('cms::lang.theme.active.not_set'));
|
||||
}
|
||||
|
||||
// Cache the results
|
||||
try {
|
||||
Cache::forever(self::ACTIVE_KEY, [
|
||||
'config' => $configuredTheme,
|
||||
'active' => $activeTheme,
|
||||
]);
|
||||
} catch (Exception $ex) {
|
||||
// Cache failed
|
||||
}
|
||||
|
||||
return $activeTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active theme object.
|
||||
* If the theme doesn't exist, returns null.
|
||||
*/
|
||||
public static function getActiveTheme(): self
|
||||
{
|
||||
if (self::$activeThemeCache !== false) {
|
||||
return self::$activeThemeCache;
|
||||
}
|
||||
|
||||
$theme = static::load(static::getActiveThemeCode());
|
||||
|
||||
|
||||
return self::$activeThemeCache = $theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the active theme in the database.
|
||||
* The active theme code is stored in the database and overrides the configuration cms.activeTheme parameter.
|
||||
* @throws ApplicationException if the directory name is invalid.
|
||||
*/
|
||||
public static function setActiveTheme(string $code): void
|
||||
{
|
||||
if (!static::isValidDirName($code)) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.theme.dir_name_invalid'));
|
||||
}
|
||||
|
||||
self::resetCache();
|
||||
|
||||
Parameter::set(self::ACTIVE_KEY, $code);
|
||||
|
||||
/**
|
||||
* @event cms.theme.setActiveTheme
|
||||
* Fires when the active theme has been changed.
|
||||
*
|
||||
* If a value is returned from this halting event, it will be used as the active
|
||||
* theme code. Example usage:
|
||||
*
|
||||
* Event::listen('cms.theme.setActiveTheme', function ($code) {
|
||||
* \Log::info("Theme has been changed to $code");
|
||||
* });
|
||||
*
|
||||
*/
|
||||
Event::fire('cms.theme.setActiveTheme', compact('code'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the edit theme code.
|
||||
* By default the edit theme is loaded from the cms.editTheme parameter,
|
||||
* but this behavior can be overridden by the cms.theme.getEditTheme event listeners.
|
||||
* If the edit theme is not defined in the configuration file, the active theme
|
||||
* is returned.
|
||||
*
|
||||
* @throws SystemException if the edit theme cannot be determined
|
||||
*/
|
||||
public static function getEditThemeCode(): string
|
||||
{
|
||||
/**
|
||||
* @event cms.theme.getEditTheme
|
||||
* Overrides the edit theme code.
|
||||
*
|
||||
* If a value is returned from this halting event, it will be used as the edit
|
||||
* theme code. Example usage:
|
||||
*
|
||||
* Event::listen('cms.theme.getEditTheme', function () {
|
||||
* return "the-edit-theme-code";
|
||||
* });
|
||||
*
|
||||
*/
|
||||
$apiResult = Event::fire('cms.theme.getEditTheme', [], true);
|
||||
if ($apiResult !== null) {
|
||||
return $apiResult;
|
||||
}
|
||||
|
||||
$editTheme = Config::get('cms.editTheme');
|
||||
if (!$editTheme) {
|
||||
$editTheme = static::getActiveThemeCode();
|
||||
}
|
||||
|
||||
if (!strlen($editTheme)) {
|
||||
throw new SystemException(Lang::get('cms::lang.theme.edit.not_set'));
|
||||
}
|
||||
|
||||
return $editTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the edit theme.
|
||||
*/
|
||||
public static function getEditTheme(): self
|
||||
{
|
||||
if (self::$editThemeCache !== false) {
|
||||
return self::$editThemeCache;
|
||||
}
|
||||
|
||||
$theme = static::load(static::getEditThemeCode());
|
||||
|
||||
|
||||
return self::$editThemeCache = $theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of all themes.
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
$it = new DirectoryIterator(themes_path());
|
||||
$it->rewind();
|
||||
|
||||
$result = [];
|
||||
foreach ($it as $fileinfo) {
|
||||
if (!$fileinfo->isDir() || $fileinfo->isDot()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$theme = static::load($fileinfo->getFilename());
|
||||
|
||||
$result[] = $theme;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the theme.yaml file and returns the theme configuration values.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
if ($this->configCache !== null) {
|
||||
return $this->configCache;
|
||||
}
|
||||
|
||||
// Attempt to load the theme's config file from whatever datasources are available.
|
||||
$sources = [
|
||||
'filesystem' => new FileDatasource(themes_path($this->getDirName()), App::make('files'))
|
||||
];
|
||||
if (static::databaseLayerEnabled()) {
|
||||
$sources['database'] = new DbDatasource($this->getDirName(), 'cms_theme_templates');
|
||||
}
|
||||
$data = (new AutoDatasource($sources))->selectOne('', 'theme', 'yaml');
|
||||
|
||||
if (!$data) {
|
||||
return $this->configCache = [];
|
||||
}
|
||||
|
||||
$config = Yaml::parse($data['content']) ?: [];
|
||||
|
||||
/**
|
||||
* @event cms.theme.extendConfig
|
||||
* Extend basic theme configuration supplied by the theme by returning an array.
|
||||
*
|
||||
* Note if planning on extending form fields, use the `cms.theme.extendFormConfig`
|
||||
* event instead.
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* Event::listen('cms.theme.extendConfig', function ($themeCode, &$config) {
|
||||
* $config['name'] = 'Winter Theme';
|
||||
* $config['description'] = 'Another great theme from Winter CMS';
|
||||
* });
|
||||
*
|
||||
*/
|
||||
Event::fire('cms.theme.extendConfig', [$this->getDirName(), &$config]);
|
||||
|
||||
return $this->configCache = $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Themes have a dedicated `form` option that provide form fields
|
||||
* for customization, this is an immutable accessor for that and
|
||||
* also an solid anchor point for extension.
|
||||
*/
|
||||
public function getFormConfig(): array
|
||||
{
|
||||
$config = $this->getConfigArray('form');
|
||||
|
||||
/**
|
||||
* @event cms.theme.extendFormConfig
|
||||
* Extend form field configuration supplied by the theme by returning an array.
|
||||
*
|
||||
* Note if you are planning on using `assetVar` to inject CSS variables from a
|
||||
* plugin registration file, make sure the plugin has elevated permissions.
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* Event::listen('cms.theme.extendFormConfig', function ($themeCode, &$config) {
|
||||
* array_set($config, 'tabs.fields.header_color', [
|
||||
* 'label' => 'Header Colour',
|
||||
* 'type' => 'colorpicker',
|
||||
* 'availableColors' => [#103141, #708598, #6cc551],
|
||||
* 'assetVar' => 'header-bg',
|
||||
* 'tab' => 'Global'
|
||||
* ]);
|
||||
* });
|
||||
*
|
||||
*/
|
||||
Event::fire('cms.theme.extendFormConfig', [$this->getDirName(), &$config]);
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an asset URL for the provided path within the theme, will use the parent theme
|
||||
* if the current theme does not actually have a directory on the filesystem (i.e. is virtual).
|
||||
*/
|
||||
public function assetUrl(?string $path): string
|
||||
{
|
||||
$expiresAt = now()->addMinutes(Config::get('cms.urlCacheTtl', 10));
|
||||
$key = sprintf('winter.cms.%s.assetUrl.%s.%s', $this->dirName, request()->getSchemeAndHttpHost(), $path);
|
||||
return Cache::remember($key, $expiresAt, function () use ($path) {
|
||||
// Handle symbolized paths
|
||||
if ($path && File::isPathSymbol($path)) {
|
||||
return Url::asset(File::localToPublic(File::symbolizePath($path)));
|
||||
}
|
||||
|
||||
$config = $this->getConfig();
|
||||
$themeDir = $this->getDirName();
|
||||
|
||||
// If the active theme does not have a directory, then just check the parent theme
|
||||
if (!File::isDirectory(themes_path($this->getDirName())) && !empty($config['parent'])) {
|
||||
$themeDir = $config['parent'];
|
||||
}
|
||||
|
||||
// Define a helper for constructing the URL
|
||||
$urlPath = function ($themeDir, $path) {
|
||||
$_url = Config::get('cms.themesPath', '/themes') . '/' . $themeDir;
|
||||
|
||||
if ($path !== null) {
|
||||
$_url .= '/' . $path;
|
||||
}
|
||||
|
||||
return $_url;
|
||||
};
|
||||
|
||||
$url = $urlPath($themeDir, $path);
|
||||
|
||||
// If the file cannot be found in the theme, generate a url for the parent theme
|
||||
if (!File::exists(base_path($url)) && !empty($config['parent']) && $themeDir !== $config['parent']) {
|
||||
$parentUrl = $urlPath($config['parent'], $path);
|
||||
// If found in the parent, return it
|
||||
if (File::exists(base_path($parentUrl))) {
|
||||
return Url::asset($parentUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Default to returning the current theme's url
|
||||
return Url::asset($url);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a value from the theme configuration file by its name.
|
||||
*/
|
||||
public function getConfigValue(string $name, mixed $default = null): mixed
|
||||
{
|
||||
return array_get($this->getConfig(), $name, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array value from the theme configuration file by its name.
|
||||
* If the value is a string, it is treated as a YAML file and loaded.
|
||||
*/
|
||||
public function getConfigArray(string $name): array
|
||||
{
|
||||
$result = array_get($this->getConfig(), $name, []);
|
||||
|
||||
if (is_string($result)) {
|
||||
$fileName = File::symbolizePath($result);
|
||||
|
||||
if (File::isLocalPath($fileName)) {
|
||||
$path = $fileName;
|
||||
}
|
||||
else {
|
||||
$path = $this->getPath().'/'.$result;
|
||||
}
|
||||
|
||||
if (!File::exists($path)) {
|
||||
throw new ApplicationException('Path does not exist: '.$path);
|
||||
}
|
||||
|
||||
$result = Yaml::parseFile($path);
|
||||
}
|
||||
|
||||
return (array) $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes to the theme.yaml file with the supplied array values.
|
||||
*
|
||||
* @throws ApplicationException if the theme.yaml file does not exist.
|
||||
*/
|
||||
public function writeConfig(array $values = [], bool $overwrite = false): void
|
||||
{
|
||||
if (!$overwrite) {
|
||||
$values = $values + (array) $this->getConfig();
|
||||
}
|
||||
|
||||
$path = $this->getPath().'/theme.yaml';
|
||||
if (!File::exists($path)) {
|
||||
throw new ApplicationException('Path does not exist: ' . $path);
|
||||
}
|
||||
|
||||
$contents = Yaml::render($values);
|
||||
File::put($path, $contents);
|
||||
$this->configCache = $values;
|
||||
|
||||
self::resetCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the theme preview image URL.
|
||||
* If the image file doesn't exist returns the placeholder image URL.
|
||||
*/
|
||||
public function getPreviewImageUrl(): string
|
||||
{
|
||||
$previewPath = $this->getConfigValue('previewImage', 'assets/images/theme-preview.png');
|
||||
|
||||
if (File::exists($this->getPath() . '/' . $previewPath)) {
|
||||
return Url::asset('themes/' . $this->getDirName() . '/' . $previewPath);
|
||||
}
|
||||
|
||||
return Url::asset('modules/cms/assets/images/default-theme-preview.png');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets any memory or cache involved with the active or edit theme.
|
||||
*/
|
||||
public static function resetCache(bool $memoryOnly = false): void
|
||||
{
|
||||
self::$activeThemeCache = false;
|
||||
self::$editThemeCache = false;
|
||||
|
||||
ThemeData::flushCache();
|
||||
|
||||
// Sometimes it may be desired to only clear the local cache of the active / edit themes instead of the persistent cache
|
||||
if (!$memoryOnly) {
|
||||
Cache::forget(self::ACTIVE_KEY);
|
||||
Cache::forget(self::EDIT_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this theme has form fields that supply customization data.
|
||||
*/
|
||||
public function hasCustomData(): bool
|
||||
{
|
||||
return (bool) $this->getConfigValue('form', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns data specific to this theme
|
||||
*/
|
||||
public function getCustomData(): ThemeData
|
||||
{
|
||||
return ThemeData::forTheme($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove data specific to this theme
|
||||
*/
|
||||
public function removeCustomData(): bool
|
||||
{
|
||||
if ($this->hasCustomData()) {
|
||||
return $this->getCustomData()->delete();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the backend localizations provided by this theme and its ancestors.
|
||||
*/
|
||||
public function registerBackendLocalization(): void
|
||||
{
|
||||
$langPath = $this->getPath() . '/lang';
|
||||
|
||||
if (File::isDirectory($langPath)) {
|
||||
Lang::addNamespace('themes.' . $this->getDirName(), $langPath);
|
||||
}
|
||||
|
||||
// Check the parent theme if present
|
||||
$config = $this->getConfig();
|
||||
if (!empty($config['parent'])) {
|
||||
$langPath = themes_path($config['parent'] . '/lang');
|
||||
if (File::isDirectory($langPath)) {
|
||||
Lang::addNamespace('themes.' . $config['parent'], $langPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks to see if the database layer has been enabled
|
||||
*/
|
||||
public static function databaseLayerEnabled(): bool
|
||||
{
|
||||
$enableDbLayer = Config::get('cms.databaseTemplates', false);
|
||||
if (is_null($enableDbLayer)) {
|
||||
$enableDbLayer = !Config::get('app.debug', false);
|
||||
}
|
||||
|
||||
$hasDb = Cache::rememberForever('cms.databaseTemplates.hasTables', function () {
|
||||
return App::hasDatabaseTable('cms_theme_templates');
|
||||
});
|
||||
|
||||
return $enableDbLayer && $hasDb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures this theme is registered as a Halcyon datasource.
|
||||
*/
|
||||
public function registerHalcyonDatasource(): void
|
||||
{
|
||||
$resolver = App::make('halcyon');
|
||||
if ($resolver->hasDatasource($this->dirName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sources = [];
|
||||
if (static::databaseLayerEnabled()) {
|
||||
$sources['database'] = new DbDatasource($this->dirName, 'cms_theme_templates');
|
||||
}
|
||||
|
||||
$sources['filesystem'] = new FileDatasource($this->getPath(), App::make('files'));
|
||||
|
||||
$config = $this->getConfig();
|
||||
if (!empty($config['parent'])) {
|
||||
if (static::databaseLayerEnabled()) {
|
||||
$sources['parent-database'] = new DbDatasource($config['parent'], 'cms_theme_templates');
|
||||
}
|
||||
|
||||
$sources['parent-filesystem'] = new FileDatasource(themes_path($config['parent']), App::make('files'));
|
||||
}
|
||||
|
||||
$datasource = count($sources) > 1
|
||||
? new AutoDatasource($sources, 'halcyon-datasource-auto-' . $this->dirName)
|
||||
: array_shift($sources);
|
||||
|
||||
$resolver->addDatasource($this->dirName, $datasource);
|
||||
|
||||
/**
|
||||
* @event cms.theme.registerHalcyonDatasource
|
||||
* Fires immediately after the theme's Datasource has been registered.
|
||||
*
|
||||
* Allows for extension of the theme Halcyon Datasource, example usage:
|
||||
*
|
||||
* use Cms\Classes\Theme;
|
||||
* use Winter\Storm\Halcyon\Datasource\Resolver;
|
||||
*
|
||||
* Event::listen('cms.theme.registerHalcyonDatasource', function (Theme $theme, Resolver $resolver) {
|
||||
* $resolver->addDatasource($theme->getDirName(), new AutoDatasource([
|
||||
* 'theme' => $theme->getDatasource(),
|
||||
* 'example' => new ExampleDatasource(),
|
||||
* ], 'example-autodatasource'));
|
||||
* });
|
||||
*
|
||||
*/
|
||||
Event::fire('cms.theme.registerHalcyonDatasource', [$this, $resolver]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the theme's datasource
|
||||
*/
|
||||
public function getDatasource(): DatasourceInterface
|
||||
{
|
||||
$resolver = App::make('halcyon');
|
||||
return $resolver->datasource($this->getDirName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the getter functionality.
|
||||
*/
|
||||
public function __get($name)
|
||||
{
|
||||
if (in_array(strtolower($name), ['id', 'path', 'dirname', 'config', 'formconfig', 'previewimageurl'])) {
|
||||
$method = 'get'. ucfirst($name);
|
||||
return $this->$method();
|
||||
}
|
||||
|
||||
if ($this->hasCustomData()) {
|
||||
return $this->getCustomData()->{$name};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an attribute exists on the object.
|
||||
*/
|
||||
public function __isset($key)
|
||||
{
|
||||
if (in_array(strtolower($key), ['id', 'path', 'dirname', 'config', 'formconfig', 'previewimageurl'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->hasCustomData()) {
|
||||
$theme = $this->getCustomData();
|
||||
return $theme->offsetExists($key);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
127
modules/cms/classes/ThemeManager.php
Normal file
127
modules/cms/classes/ThemeManager.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use File;
|
||||
use ApplicationException;
|
||||
use System\Models\Parameter;
|
||||
use Cms\Classes\Theme as CmsTheme;
|
||||
|
||||
/**
|
||||
* Theme manager
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class ThemeManager
|
||||
{
|
||||
use \Winter\Storm\Support\Traits\Singleton;
|
||||
|
||||
//
|
||||
// Gateway spawned
|
||||
//
|
||||
|
||||
/**
|
||||
* Returns a collection of themes installed via the update gateway
|
||||
* @return array
|
||||
*/
|
||||
public function getInstalled()
|
||||
{
|
||||
return Parameter::get('system::theme.history', []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a theme has ever been installed before.
|
||||
* @param string $name Theme code
|
||||
* @return boolean
|
||||
*/
|
||||
public function isInstalled($name)
|
||||
{
|
||||
return array_key_exists($name, Parameter::get('system::theme.history', []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags a theme as being installed, so it is not downloaded twice.
|
||||
* @param string $code Theme code
|
||||
* @param string|null $dirName
|
||||
*/
|
||||
public function setInstalled($code, $dirName = null)
|
||||
{
|
||||
if (!$dirName) {
|
||||
$dirName = strtolower(str_replace('.', '-', $code));
|
||||
}
|
||||
|
||||
$history = Parameter::get('system::theme.history', []);
|
||||
$history[$code] = $dirName;
|
||||
Parameter::set('system::theme.history', $history);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags a theme as being uninstalled.
|
||||
* @param string $code Theme code
|
||||
*/
|
||||
public function setUninstalled($code)
|
||||
{
|
||||
$history = Parameter::get('system::theme.history', []);
|
||||
if (array_key_exists($code, $history)) {
|
||||
unset($history[$code]);
|
||||
}
|
||||
|
||||
Parameter::set('system::theme.history', $history);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an installed theme's code from it's dirname.
|
||||
* @return string
|
||||
*/
|
||||
public function findByDirName($dirName)
|
||||
{
|
||||
$installed = $this->getInstalled();
|
||||
foreach ($installed as $code => $name) {
|
||||
if ($dirName == $name) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
//
|
||||
// Management
|
||||
//
|
||||
|
||||
/**
|
||||
* Completely delete a theme from the system.
|
||||
* @param string $theme Theme code/namespace
|
||||
* @return void
|
||||
*/
|
||||
public function deleteTheme($theme)
|
||||
{
|
||||
if (!$theme) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_string($theme)) {
|
||||
$theme = CmsTheme::load($theme);
|
||||
}
|
||||
|
||||
if ($theme->isActiveTheme()) {
|
||||
throw new ApplicationException(trans('cms::lang.theme.delete_active_theme_failed'));
|
||||
}
|
||||
|
||||
$theme->removeCustomData();
|
||||
|
||||
/*
|
||||
* Delete from file system
|
||||
*/
|
||||
$themePath = $theme->getPath();
|
||||
if (File::isDirectory($themePath)) {
|
||||
File::deleteDirectory($themePath);
|
||||
}
|
||||
|
||||
/*
|
||||
* Set uninstalled
|
||||
*/
|
||||
if ($themeCode = $this->findByDirName($theme->getDirName())) {
|
||||
$this->setUninstalled($themeCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
modules/cms/classes/asset/fields.yaml
Normal file
26
modules/cms/classes/asset/fields.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
# ===================================
|
||||
# Form Field Definitions
|
||||
# ===================================
|
||||
|
||||
fields:
|
||||
fileName:
|
||||
label: cms::lang.editor.filename
|
||||
attributes:
|
||||
default-focus: 1
|
||||
|
||||
toolbar:
|
||||
type: partial
|
||||
path: content_toolbar
|
||||
cssClass: collapse-visible
|
||||
|
||||
tabs:
|
||||
cssClass: master-area
|
||||
|
||||
secondaryTabs:
|
||||
stretch: true
|
||||
fields:
|
||||
content:
|
||||
tab: cms::lang.editor.content
|
||||
stretch: true
|
||||
type: codeeditor
|
||||
language: css
|
||||
28
modules/cms/classes/content/fields.yaml
Normal file
28
modules/cms/classes/content/fields.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
# ===================================
|
||||
# Form Field Definitions
|
||||
# ===================================
|
||||
|
||||
fields:
|
||||
fileName:
|
||||
label: cms::lang.editor.filename
|
||||
attributes:
|
||||
default-focus: 1
|
||||
|
||||
toolbar:
|
||||
type: partial
|
||||
path: content_toolbar
|
||||
cssClass: collapse-visible
|
||||
|
||||
components: Cms\FormWidgets\Components
|
||||
|
||||
tabs:
|
||||
cssClass: master-area
|
||||
|
||||
secondaryTabs:
|
||||
stretch: true
|
||||
fields:
|
||||
markup:
|
||||
tab: cms::lang.editor.content
|
||||
stretch: true
|
||||
type: codeeditor
|
||||
language: html
|
||||
45
modules/cms/classes/layout/fields.yaml
Normal file
45
modules/cms/classes/layout/fields.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
# ===================================
|
||||
# Form Field Definitions
|
||||
# ===================================
|
||||
|
||||
fields:
|
||||
fileName:
|
||||
label: cms::lang.editor.filename
|
||||
span: left
|
||||
attributes:
|
||||
default-focus: 1
|
||||
|
||||
settings[description]:
|
||||
label: cms::lang.editor.description
|
||||
span: right
|
||||
|
||||
toolbar:
|
||||
type: partial
|
||||
path: layout_toolbar
|
||||
cssClass: collapse-visible
|
||||
|
||||
components: Cms\FormWidgets\Components
|
||||
|
||||
tabs:
|
||||
cssClass: master-area
|
||||
|
||||
secondaryTabs:
|
||||
stretch: true
|
||||
fields:
|
||||
markup:
|
||||
tab: cms::lang.editor.markup
|
||||
stretch: true
|
||||
type: codeeditor
|
||||
language: twig
|
||||
|
||||
safemode_notice:
|
||||
tab: cms::lang.editor.code
|
||||
type: partial
|
||||
hidden: true
|
||||
cssClass: p-b-0
|
||||
|
||||
code:
|
||||
tab: cms::lang.editor.code
|
||||
stretch: true
|
||||
type: codeeditor
|
||||
language: php
|
||||
87
modules/cms/classes/page/fields.yaml
Normal file
87
modules/cms/classes/page/fields.yaml
Normal file
@@ -0,0 +1,87 @@
|
||||
# ===================================
|
||||
# Form Field Definitions
|
||||
# ===================================
|
||||
|
||||
fields:
|
||||
settings[title]:
|
||||
span: left
|
||||
label: cms::lang.editor.title
|
||||
placeholder: cms::lang.editor.new_title
|
||||
attributes:
|
||||
default-focus: 1
|
||||
|
||||
settings[url]:
|
||||
span: right
|
||||
placeholder: /
|
||||
label: cms::lang.editor.url
|
||||
preset:
|
||||
field: settings[title]
|
||||
type: url
|
||||
|
||||
toolbar:
|
||||
type: partial
|
||||
path: page_toolbar
|
||||
cssClass: collapse-visible
|
||||
|
||||
components: Cms\FormWidgets\Components
|
||||
|
||||
tabs:
|
||||
cssClass: master-area
|
||||
fields:
|
||||
fileName:
|
||||
tab: cms::lang.editor.settings
|
||||
span: left
|
||||
label: cms::lang.editor.filename
|
||||
preset:
|
||||
field: settings[title]
|
||||
type: file
|
||||
|
||||
settings[layout]:
|
||||
tab: cms::lang.editor.settings
|
||||
span: right
|
||||
label: cms::lang.editor.layout
|
||||
type: dropdown
|
||||
options: getLayoutOptions
|
||||
|
||||
settings[description]:
|
||||
tab: cms::lang.editor.settings
|
||||
label: cms::lang.editor.description
|
||||
type: textarea
|
||||
size: tiny
|
||||
|
||||
settings[meta_title]:
|
||||
tab: cms::lang.editor.meta
|
||||
label: cms::lang.editor.meta_title
|
||||
|
||||
settings[meta_description]:
|
||||
tab: cms::lang.editor.meta
|
||||
label: cms::lang.editor.meta_description
|
||||
type: textarea
|
||||
size: tiny
|
||||
|
||||
settings[is_hidden]:
|
||||
tab: cms::lang.editor.settings
|
||||
label: cms::lang.editor.hidden
|
||||
type: checkbox
|
||||
comment: cms::lang.editor.hidden_comment
|
||||
|
||||
secondaryTabs:
|
||||
stretch: true
|
||||
fields:
|
||||
markup:
|
||||
tab: cms::lang.editor.markup
|
||||
stretch: true
|
||||
type: codeeditor
|
||||
language: twig
|
||||
|
||||
safemode_notice:
|
||||
tab: cms::lang.editor.code
|
||||
type: partial
|
||||
hidden: true
|
||||
cssClass: p-b-0
|
||||
|
||||
code:
|
||||
tab: cms::lang.editor.code
|
||||
stretch: true
|
||||
type: codeeditor
|
||||
language: php
|
||||
45
modules/cms/classes/partial/fields.yaml
Normal file
45
modules/cms/classes/partial/fields.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
# ===================================
|
||||
# Form Field Definitions
|
||||
# ===================================
|
||||
|
||||
fields:
|
||||
fileName:
|
||||
span: left
|
||||
label: cms::lang.editor.filename
|
||||
attributes:
|
||||
default-focus: 1
|
||||
|
||||
settings[description]:
|
||||
span: right
|
||||
label: cms::lang.editor.description
|
||||
|
||||
toolbar:
|
||||
type: partial
|
||||
path: partial_toolbar
|
||||
cssClass: collapse-visible
|
||||
|
||||
components: Cms\FormWidgets\Components
|
||||
|
||||
tabs:
|
||||
cssClass: master-area
|
||||
|
||||
secondaryTabs:
|
||||
stretch: true
|
||||
fields:
|
||||
markup:
|
||||
tab: cms::lang.editor.markup
|
||||
stretch: true
|
||||
type: codeeditor
|
||||
language: twig
|
||||
|
||||
safemode_notice:
|
||||
tab: cms::lang.editor.code
|
||||
type: partial
|
||||
hidden: true
|
||||
cssClass: p-b-0
|
||||
|
||||
code:
|
||||
tab: cms::lang.editor.code
|
||||
stretch: true
|
||||
type: codeeditor
|
||||
language: php
|
||||
58
modules/cms/classes/theme/fields.yaml
Normal file
58
modules/cms/classes/theme/fields.yaml
Normal file
@@ -0,0 +1,58 @@
|
||||
# ===================================
|
||||
# Form Field Definitions
|
||||
# ===================================
|
||||
|
||||
tabs:
|
||||
defaultTab: cms::lang.theme.default_tab
|
||||
fields:
|
||||
|
||||
name:
|
||||
label: cms::lang.theme.name_label
|
||||
placeholder: cms::lang.theme.name_create_placeholder
|
||||
span: auto
|
||||
required: true
|
||||
attributes:
|
||||
default-focus: 1
|
||||
|
||||
dir_name@create:
|
||||
label: cms::lang.theme.dir_name_label
|
||||
placeholder: cms::lang.theme.dir_name_create_label
|
||||
span: auto
|
||||
preset: name
|
||||
required: true
|
||||
|
||||
dir_name@update:
|
||||
label: cms::lang.theme.dir_name_label
|
||||
disabled: true
|
||||
span: auto
|
||||
|
||||
scaffold@create:
|
||||
label: cms::lang.theme.scaffold.label
|
||||
type: balloon-selector
|
||||
span: full
|
||||
required: true
|
||||
options:
|
||||
empty: cms::lang.theme.scaffold.empty
|
||||
less: cms::lang.theme.scaffold.less
|
||||
tailwind: cms::lang.theme.scaffold.tailwind
|
||||
default: less
|
||||
|
||||
description:
|
||||
label: cms::lang.theme.description_label
|
||||
placeholder: cms::lang.theme.description_placeholder
|
||||
type: textarea
|
||||
size: tiny
|
||||
|
||||
author:
|
||||
label: cms::lang.theme.author_label
|
||||
placeholder: cms::lang.theme.author_placeholder
|
||||
span: auto
|
||||
|
||||
homepage:
|
||||
label: cms::lang.theme.homepage_label
|
||||
placeholder: cms::lang.theme.homepage_placeholder
|
||||
span: auto
|
||||
|
||||
code:
|
||||
label: cms::lang.theme.code_label
|
||||
placeholder: cms::lang.theme.code_placeholder
|
||||
Reference in New Issue
Block a user