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

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

View File

@@ -0,0 +1,40 @@
<?php namespace System\Twig;
use System\Twig\Loader as TwigLoader;
use Twig\Environment as TwigEnvironment;
use Illuminate\Contracts\View\Engine as EngineInterface;
/**
* View engine used by the system, used for converting .htm files to twig.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class Engine implements EngineInterface
{
/**
* @var TwigEnvironment
*/
protected $environment;
/**
* Constructor
*/
public function __construct(TwigEnvironment $environment)
{
$this->environment = $environment;
}
public function get($path, array $vars = [])
{
$previousAllow = TwigLoader::$allowInclude;
TwigLoader::$allowInclude = true;
$template = $this->environment->load($path);
TwigLoader::$allowInclude = $previousAllow;
return $template->render($vars);
}
}

View File

@@ -0,0 +1,175 @@
<?php namespace System\Twig;
use Url;
use System\Classes\ImageResizer;
use System\Classes\MediaLibrary;
use System\Classes\MarkupManager;
use Twig\TwigFilter as TwigSimpleFilter;
use Twig\TwigFunction as TwigSimpleFunction;
use Twig\Extension\AbstractExtension as TwigExtension;
/**
* The System Twig extension class implements common Twig functions and filters.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class Extension extends TwigExtension
{
/**
* @var \System\Classes\MarkupManager A reference to the markup manager instance.
*/
protected $markupManager;
/**
* Creates the extension instance.
*/
public function __construct()
{
$this->markupManager = MarkupManager::instance();
}
/**
* Returns a list of functions to add to the existing list.
*
* @return array An array of functions
*/
public function getFunctions()
{
$functions = [
new TwigSimpleFunction('app', [$this, 'appFilter']),
new TwigSimpleFunction('media', [$this, 'mediaFilter']),
new TwigSimpleFunction('asset', [$this, 'assetFilter']),
new TwigSimpleFunction('resize', [$this, 'resizeFilter']),
new TwigSimpleFunction('imageWidth', [$this, 'imageWidthFilter']),
new TwigSimpleFunction('imageHeight', [$this, 'imageHeightFilter']),
];
/*
* Include extensions provided by plugins
*/
$functions = $this->markupManager->makeTwigFunctions($functions);
return $functions;
}
/**
* Returns a list of filters this extensions provides.
*
* @return array An array of filters
*/
public function getFilters()
{
$filters = [
new TwigSimpleFilter('app', [$this, 'appFilter']),
new TwigSimpleFilter('media', [$this, 'mediaFilter']),
new TwigSimpleFilter('asset', [$this, 'assetFilter']),
new TwigSimpleFilter('resize', [$this, 'resizeFilter']),
new TwigSimpleFilter('imageWidth', [$this, 'imageWidthFilter']),
new TwigSimpleFilter('imageHeight', [$this, 'imageHeightFilter']),
];
/*
* Include extensions provided by plugins
*/
$filters = $this->markupManager->makeTwigFilters($filters);
return $filters;
}
/**
* Returns a list of token parsers this extensions provides.
*
* @return array An array of token parsers
*/
public function getTokenParsers()
{
$parsers = [
new SpacelessTokenParser,
new FilterTokenParser,
];
/*
* Include extensions provided by plugins
*/
$parsers = $this->markupManager->makeTwigTokenParsers($parsers);
return $parsers;
}
/**
* Converts supplied URL to one relative to the website root.
* @param mixed $url Specifies the application-relative URL
* @return string
*/
public function appFilter($url)
{
return Url::to($url);
}
/**
* Converts supplied file to a URL relative to the media library.
* @param string $file Specifies the media-relative file
* @return string
*/
public function mediaFilter($file)
{
return MediaLibrary::url($file);
}
/**
* Converts supplied file to a URL relative to the `app.asset_url` config.
* @param string $file Specifies the asset-relative file
* @return string
*/
public function assetFilter($file)
{
return Url::asset($file);
}
/**
* Converts supplied input into a URL that will return the desired resized image
*
* @param mixed $image Supported values below:
* ['disk' => Illuminate\Filesystem\FilesystemAdapter, 'path' => string, 'source' => string, 'fileModel' => FileModel|void],
* instance of Winter\Storm\Database\Attach\File,
* string containing URL or path accessible to the application's filesystem manager
* @param integer|bool|null $width Desired width of the resized image
* @param integer|bool|null $height Desired height of the resized image
* @param array|null $options Array of options to pass to the resizer
* @throws Exception If the provided image was unable to be processed
* @return string
*/
public function resizeFilter($image, $width = null, $height = null, $options = [])
{
return ImageResizer::filterGetUrl($image, $width, $height, $options);
}
/**
* Gets the width in pixels of the provided image source
*
* @param mixed $image Supported values below:
* ['disk' => Illuminate\Filesystem\FilesystemAdapter, 'path' => string, 'source' => string, 'fileModel' => FileModel|void],
* instance of Winter\Storm\Database\Attach\File,
* string containing URL or path accessible to the application's filesystem manager
* @return int
*/
public function imageWidthFilter($image)
{
return @ImageResizer::filterGetDimensions($image)['width'];
}
/**
* Gets the height in pixels of the provided image source
*
* @param mixed $image Supported values below:
* ['disk' => Illuminate\Filesystem\FilesystemAdapter, 'path' => string, 'source' => string, 'fileModel' => FileModel|void],
* instance of Winter\Storm\Database\Attach\File,
* string containing URL or path accessible to the application's filesystem manager
* @return int
*/
public function imageHeightFilter($image)
{
return @ImageResizer::filterGetDimensions($image)['height'];
}
}

View File

@@ -0,0 +1,49 @@
<?php namespace System\Twig;
use Twig\Node\BlockNode;
use Twig\Node\Expression\BlockReferenceExpression;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\PrintNode;
use Twig\Token;
use Twig\TokenParser\AbstractTokenParser;
/**
* Filters a section of a template by applying filters.
*
* {% filter upper %}
* This text becomes uppercase
* {% endfilter %}
*
* @deprecated since Twig 2.9, to be removed in 3.0 (use the "apply" tag instead)
*/
final class FilterTokenParser extends AbstractTokenParser
{
public function parse(Token $token)
{
@trigger_error('The "filter" tag is deprecated since Twig 2.9, use the "apply" tag instead.', E_USER_DEPRECATED);
$name = $this->parser->getVarName();
$ref = new BlockReferenceExpression(new ConstantExpression($name, $token->getLine()), null, $token->getLine(), $this->getTag());
$filter = $this->parser->getExpressionParser()->parseFilterExpressionRaw($ref, $this->getTag());
$this->parser->getStream()->expect(/* Token::BLOCK_END_TYPE */ 3);
$body = $this->parser->subparse([$this, 'decideBlockEnd'], true);
$this->parser->getStream()->expect(/* Token::BLOCK_END_TYPE */ 3);
$block = new BlockNode($name, $body, $token->getLine());
$this->parser->setBlock($name, $block);
return new PrintNode($filter, $token->getLine(), $this->getTag());
}
public function decideBlockEnd(Token $token)
{
return $token->test('endfilter');
}
public function getTag()
{
return 'filter';
}
}

View File

@@ -0,0 +1,70 @@
<?php namespace System\Twig;
use System\Twig\Node\GetAttrNode;
use Twig\Environment;
use Twig\Node\Expression\GetAttrExpression;
use Twig\Node\Node;
use Twig\NodeVisitor\NodeVisitorInterface;
/**
* GetAttrAdjuster swaps every attribute-access node (GetAttrExpression) for the custom
* GetAttrNode, so that method calls in sandbox mode route through
* SecurityPolicy::castMethodObjectToSafeObject. This is what enables the SafeCollection /
* SafePaginator protections. Only exact GetAttrExpression instances are swapped (not the
* replacement GetAttrNode), so the traversal never re-wraps.
*
* @package winter\wn-system-module
*/
class GetAttrAdjuster implements NodeVisitorInterface
{
/**
* @inheritDoc
*/
public function enterNode(Node $node, Environment $env): Node
{
if (get_class($node) !== GetAttrExpression::class) {
return $node;
}
$nodes = [
'node' => $node->getNode('node'),
'attribute' => $node->getNode('attribute'),
];
if ($node->hasNode('arguments')) {
$nodes['arguments'] = $node->getNode('arguments');
}
$isDefinedTest = $node->isDefinedTestEnabled();
$attributes = [
'type' => $node->getAttribute('type'),
'ignore_strict_check' => $node->getAttribute('ignore_strict_check'),
'optimizable' => $node->getAttribute('optimizable'),
];
$getAttrNode = new GetAttrNode($nodes, $attributes, $node->getTemplateLine());
if ($isDefinedTest) {
$getAttrNode->enableDefinedTest();
}
return $getAttrNode;
}
/**
* @inheritDoc
*/
public function leaveNode(Node $node, Environment $env): ?Node
{
return $node;
}
/**
* @inheritDoc
*/
public function getPriority()
{
return 0;
}
}

View File

@@ -0,0 +1,103 @@
<?php namespace System\Twig;
use App;
use Exception;
use File;
use InvalidArgumentException;
use Twig\Source as TwigSource;
use Twig\Error\LoaderError;
use Twig\Loader\LoaderInterface as TwigLoaderInterface;
use Winter\Storm\Support\Str;
/**
* This class implements a Twig template loader for the core system and backend.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class Loader implements TwigLoaderInterface
{
/**
* @var bool Allow any local file
*/
public static $allowInclude = false;
/**
* @var array Cache
*/
protected $cache = [];
/**
* Gets the path of a view file.
*/
protected function findTemplate(string $name): string
{
$finder = App::make('view')->getFinder();
if (isset($this->cache[$name])) {
return $this->cache[$name];
}
if (static::$allowInclude === true && File::isFile($name)) {
return $this->cache[$name] = $name;
}
try {
$path = $finder->find($name);
} catch (InvalidArgumentException $ex) {
if (Str::contains($ex->getMessage(), 'not found')) {
throw new LoaderError($ex->getMessage());
}
throw $ex;
}
return $this->cache[$name] = $path;
}
/**
* Returns the Twig content string.
* This step is cached internally by Twig.
*/
public function getSourceContext(string $name): TwigSource
{
return new TwigSource(File::get($this->findTemplate($name)), $name);
}
/**
* Returns the Twig cache key.
*/
public function getCacheKey(string $name): string
{
return $this->findTemplate($name);
}
/**
* Determines if the content is fresh.
*/
public function isFresh(string $name, int $time): bool
{
return File::lastModified($this->findTemplate($name)) <= $time;
}
/**
* Returns the file name of the loaded template.
*/
public function getFilename(string $name): string
{
return $this->findTemplate($name);
}
/**
* Checks that the template exists.
*/
public function exists(string $name): bool
{
try {
$this->findTemplate($name);
return true;
}
catch (Exception $exception) {
return false;
}
}
}

View File

@@ -0,0 +1,59 @@
<?php namespace System\Twig;
use Twig\Node\Node as TwigNode;
use Twig\Compiler as TwigCompiler;
/**
* Represents a partial node
*
* @package winter\wn-cms-module
* @author Alexey Bobkov, Samuel Georges
*/
class MailPartialNode extends TwigNode
{
public function __construct(TwigNode $nodes, $paramNames, $body, $lineno, $tag = 'partial')
{
$nodes = ['nodes' => $nodes];
if ($body) {
$nodes['body'] = $body;
}
parent::__construct($nodes, ['names' => $paramNames], $lineno, $tag);
}
/**
* Compiles the node to PHP.
*
* @param TwigCompiler $compiler A TwigCompiler instance
*/
public function compile(TwigCompiler $compiler)
{
$compiler->addDebugInfo($this);
$compiler->write("\$context['__system_partial_params'] = [];\n");
if ($this->hasNode('body')) {
$compiler
->addDebugInfo($this)
->write('ob_start();')
->subcompile($this->getNode('body'))
->write("\$context['__system_partial_params']['body'] = ob_get_clean();");
}
for ($i = 1; $i < count($this->getNode('nodes')); $i++) {
$compiler->write("\$context['__system_partial_params']['".$this->getAttribute('names')[$i-1]."'] = ");
$compiler->subcompile($this->getNode('nodes')->getNode($i));
$compiler->write(";\n");
}
$compiler
->write("echo \System\Classes\MailManager::instance()->renderPartial(")
->subcompile($this->getNode('nodes')->getNode(0))
->write(", \$context['__system_partial_params']")
->write(");\n")
;
$compiler->write("unset(\$context['__system_partial_params']);\n");
}
}

View File

@@ -0,0 +1,94 @@
<?php namespace System\Twig;
use Twig\Node\Node as TwigNode;
use Twig\Token as TwigToken;
use Twig\TokenParser\AbstractTokenParser as TwigTokenParser;
use Twig\Error\SyntaxError as TwigErrorSyntax;
/**
* Parser for the `{% partial %}` Twig tag.
*
* {% partial "sidebar" %}
*
* {% partial "sidebar" name='John' %}
*
* {% partial "sidebar" name='John', year=2013 %}
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges
*/
class MailPartialTokenParser extends TwigTokenParser
{
/**
* Parses a token and returns a node.
*
* @param TwigToken $token A TwigToken instance
* @return TwigNode A TwigNode instance
*/
public function parse(TwigToken $token)
{
$lineno = $token->getLine();
$stream = $this->parser->getStream();
$name = $this->parser->getExpressionParser()->parseExpression();
$paramNames = [];
$nodes = [$name];
$hasBody = false;
$body = null;
$end = false;
while (!$end) {
$current = $stream->next();
if (
$current->test(TwigToken::NAME_TYPE, 'body') &&
!$stream->test(TwigToken::OPERATOR_TYPE, '=')
) {
$hasBody = true;
$current = $stream->next();
}
switch ($current->getType()) {
case TwigToken::NAME_TYPE:
$paramNames[] = $current->getValue();
$stream->expect(TwigToken::OPERATOR_TYPE, '=');
$nodes[] = $this->parser->getExpressionParser()->parseExpression();
break;
case TwigToken::BLOCK_END_TYPE:
$end = true;
break;
default:
throw new TwigErrorSyntax(
sprintf('Invalid syntax in the partial tag. Line %s', $lineno),
$stream->getCurrent()->getLine(),
$stream->getSourceContext()
);
break;
}
}
if ($hasBody) {
$body = $this->parser->subparse([$this, 'decidePartialEnd'], true);
$stream->expect(TwigToken::BLOCK_END_TYPE);
}
return new MailPartialNode(new TwigNode($nodes), $paramNames, $body, $token->getLine(), $this->getTag());
}
public function decidePartialEnd(TwigToken $token)
{
return $token->test('endpartial');
}
/**
* Gets the tag name associated with this token parser.
*
* @return string The tag name
*/
public function getTag()
{
return 'partial';
}
}

View File

@@ -0,0 +1,491 @@
<?php
namespace System\Twig;
use Cms\Classes\Controller;
use Cms\Classes\Theme;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\ConnectionResolverInterface;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Model as DbModel;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Pagination\AbstractCursorPaginator;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Session\SessionManager;
use Illuminate\Support\Enumerable;
use System\Twig\SecurityPolicy\SafeCollection;
use System\Twig\SecurityPolicy\SafePaginator;
use Twig\Markup;
use Twig\Sandbox\SecurityNotAllowedFunctionError;
use Twig\Sandbox\SecurityNotAllowedMethodError;
use Twig\Sandbox\SecurityNotAllowedPropertyError;
use Twig\Sandbox\SecurityPolicyInterface;
use Twig\Template;
use Winter\Storm\Halcyon\Builder as HalcyonBuilder;
use Winter\Storm\Halcyon\Datasource\DatasourceInterface;
use Winter\Storm\Halcyon\Model as HalcyonModel;
/**
* SecurityPolicy globally blocks accessibility of certain methods and properties.
*
* The policy is a blocklist, but it models the real PHP forwarding behaviour of the
* database layer via $blockedForwarders: because `Model::__call` transparently forwards
* to the Eloquent Builder, which forwards to the Query Builder, a method blocked on the
* Query Builder is also blocked when reached through a Model, Eloquent Builder or Relation.
* This is what makes the blocklist complete instead of a game of whack-a-mole.
*
* @package winter\wn-system-module
* @author Alexey Bobkov, Samuel Georges, Luke Towers, Ben Thomson
*/
final class SecurityPolicy implements SecurityPolicyInterface
{
/**
* @var array<string, string[]> List of forbidden methods, grouped by applicable instance.
*/
protected $blockedMethods = [
'*' => [
// Prevent accessing Twig itself
'getTwig',
// Prevent extensions of any objects
'addDynamicMethod',
'addDynamicProperty',
'extendClassWith',
'implementClassWith',
'getClassExtension',
'extendableSet',
// Prevent directly invoking the magic/extension call machinery
'extend',
'extendableCall',
'extendableCallStatic',
'extendableExtendCallback',
'extensionExtendCallback',
'__call',
'__callStatic',
'__invoke',
// Prevent Laravel Macroable injection
'macro',
'mixin',
// Prevent binding to, or firing, events
'bindEvent',
'bindEventOnce',
'fireEvent',
'fireSystemEvent',
],
// Prevent some controller methods. The controller is a fixed, known object; these
// methods run nested page cycles, render arbitrary partials, or read files.
Controller::class => [
'runPage',
'renderPage',
'getLoader',
'run',
'combineAssets',
'renderPartial',
'renderContent',
],
// Prevent model data modification. Methods that forward to the query layer
// (increment, decrement, touch, getConnection, ...) are covered transitively by
// $blockedForwarders; only methods that physically live on the Model are listed here.
DbModel::class => [
'fill',
'forceFill',
'setAttribute',
'setRawAttributes',
'save',
'saveQuietly',
'saveOrFail',
'push',
'pushQuietly',
'update',
'updateQuietly',
'updateOrFail',
'delete',
'deleteQuietly',
'deleteOrFail',
'forceDelete',
'destroy',
'forceDestroy',
'restore',
'restoreQuietly',
'getQuery',
// Re-pointing the table/connection would allow reading arbitrary tables/databases
'setTable',
'setConnection',
'on',
'onWriteConnection',
'setKeyName',
'setKeyType',
'setIncrementing',
'setPerPage',
'setDateFormat',
'offsetSet',
'offsetUnset',
// Disabling mass-assignment / event protection, or executing callbacks
'unguard',
'reguard',
'unguarded',
'withoutEvents',
'withoutTouching',
'withoutTouchingOn',
// getConnectionResolver returns the DatabaseManager, whose __call proxies raw SQL
'getConnectionResolver',
'setConnectionResolver',
'unsetConnectionResolver',
'flushEventListeners',
'getEventDispatcher',
'setEventDispatcher',
'unsetEventDispatcher',
],
EloquentBuilder::class => [
'forceDelete',
'create',
'createQuietly',
'forceCreate',
'forceCreateQuietly',
'firstOrCreate',
'createOrFirst',
'updateOrCreate',
'incrementOrCreate',
'fillAndInsert',
'fillAndInsertOrIgnore',
'fillAndInsertGetId',
'touch',
'update',
'delete',
'upsert',
],
QueryBuilder::class => [
'insert',
'insertOrIgnore',
'insertGetId',
'insertUsing',
'insertOrIgnoreUsing',
'update',
'updateFrom',
'updateOrInsert',
'upsert',
'delete',
'truncate',
'increment',
'incrementEach',
'incrementQuietly',
'decrement',
'decrementEach',
'decrementQuietly',
// Re-pointing the table
'from',
'fromRaw',
'fromSub',
// Connection / raw SQL
'getConnection',
'toRawSql',
'selectRaw',
'whereRaw',
'orWhereRaw',
'havingRaw',
'orHavingRaw',
'orderByRaw',
'groupByRaw',
'joinSub',
'leftJoinSub',
'rightJoinSub',
'crossJoinSub',
'raw',
'rawValue',
'dd',
'dump',
'ddRawSql',
// callable-typed executors (string callables would execute)
'when',
'unless',
'each',
'eachById',
'chunk',
'chunkById',
'chunkByIdDesc',
'chunkMap',
'tap',
'pipe',
],
Relation::class => [
'attach',
'detach',
'sync',
'syncWithPivotValues',
'syncWithoutDetaching',
'toggle',
'updateExistingPivot',
'save',
'saveQuietly',
'saveMany',
'saveManyQuietly',
'create',
'createQuietly',
'createMany',
'createManyQuietly',
'forceCreate',
'forceCreateQuietly',
'push',
'update',
'updateOrCreate',
'firstOrCreate',
'firstOrNew',
'createOrFirst',
'delete',
'forceDelete',
'associate',
'dissociate',
'make',
'makeMany',
],
HalcyonModel::class => [
'fill',
'setAttribute',
'setRawAttributes',
'setSettingsAttribute',
'setFileNameAttribute',
'save',
'push',
'update',
'delete',
'forceDelete',
'getQuery',
'getDatasource',
],
HalcyonBuilder::class => [
'insert',
'update',
'delete',
'forceDelete',
'truncate',
],
DatasourceInterface::class => [
'insert',
'update',
'delete',
'forceDelete',
'write',
'usingSource',
'pushToSource',
'removeFromSource',
'select',
'selectOne',
],
Theme::class => [
'setDirName',
'registerHalcyonDatasource',
'getDatasource',
'writeConfig',
'removeCustomData',
],
];
/**
* @var array<string, string> Maps a class to the class its __call forwards to, so the
* sandbox enforces the destination's blocklist for a method reached through the source.
* The chain is walked transitively (Model -> Eloquent Builder -> Query Builder).
*/
protected $blockedForwarders = [
EloquentBuilder::class => QueryBuilder::class,
DbModel::class => EloquentBuilder::class,
Relation::class => EloquentBuilder::class,
];
/**
* @var array<string, string[]> List of allowed methods, grouped by applicable instance.
* An empty list denies every method on that type (deny-all lock).
*/
protected $allowedMethods = [
SessionManager::class => [
'put',
'get',
'has',
'forget',
'flush',
'pull',
],
// Locked down entirely: no template legitimately calls raw database or event objects.
ConnectionInterface::class => [],
ConnectionResolverInterface::class => [],
Dispatcher::class => [],
];
/**
* @var array<string, string[]> List of forbidden properties, grouped by applicable instance.
*/
protected $blockedProperties = [
Theme::class => [
'datasource',
],
];
/**
* @var string[] Twig functions that are not allowed (info-disclosure surface).
*/
protected $blockedFunctions = [
'source',
'constant',
'enum_cases',
];
/**
* Constructor
*/
public function __construct()
{
$properties = [
'blockedMethods',
'allowedMethods',
'blockedProperties',
];
foreach ($properties as $property) {
foreach ($this->{$property} as $type => $values) {
$this->{$property}[$type] = array_map('strtolower', $values);
}
}
$this->blockedFunctions = array_map('strtolower', $this->blockedFunctions);
}
/**
* Check the provided arguments against this security policy
*
* @param array $tags Array of tags to be checked against the policy ['tag', 'tag2', 'etc']
* @param array $filters Array of filters to be checked against the policy ['filter', 'filter2', 'etc']
* @param array $functions Array of funtions to be checked against the policy ['function', 'function2', 'etc']
* @throws SecurityNotAllowedFunctionError if a given function is not allowed
*/
public function checkSecurity($tags, $filters, $functions): void
{
foreach ($functions as $function) {
if (in_array(strtolower($function), $this->blockedFunctions)) {
throw new SecurityNotAllowedFunctionError(sprintf('Function "%s" is not allowed.', $function), $function);
}
}
}
/**
* Checks if a given property is permitted to be accessed on a given object
*
* @param object $obj
* @param string $property
* @throws SecurityNotAllowedPropertyError
*/
public function checkPropertyAllowed($obj, $property): void
{
// No need to check Twig internal objects
if ($obj instanceof Template || $obj instanceof Markup) {
return;
}
$property = strtolower($property);
foreach ($this->blockedProperties as $type => $properties) {
if ($obj instanceof $type && in_array($property, $properties)) {
$class = get_class($obj);
throw new SecurityNotAllowedPropertyError(sprintf('Getting "%s" property in a "%s" object is blocked.', $property, $class), $class, $property);
}
}
}
/**
* Checks if a given method is allowed to be called on a given object
*
* @param object $obj
* @param string $method
* @throws SecurityNotAllowedMethodError
*/
public function checkMethodAllowed($obj, $method): void
{
// No need to check Twig internal objects
if ($obj instanceof Template || $obj instanceof Markup) {
return;
}
$method = strtolower($method);
if (in_array($method, $this->blockedMethods['*'])) {
$this->throwMethodError($obj, $method);
}
foreach ($this->allowedMethods as $type => $methods) {
if ($obj instanceof $type && !in_array($method, $methods)) {
$this->throwMethodError($obj, $method);
}
}
foreach ($this->blockedMethods as $type => $methods) {
if ($type === '*') {
continue;
}
if ($obj instanceof $type && in_array($method, $methods)) {
$this->throwMethodError($obj, $method);
}
}
// Enforce the blocklists of any class this object's __call forwards to, transitively.
// This closes the forwarding escape (e.g. `model.increment()` reaching the Query Builder).
foreach ($this->blockedForwarders as $sourceClass => $targetClass) {
if (!($obj instanceof $sourceClass)) {
continue;
}
$cursor = $targetClass;
$seen = [];
while ($cursor !== null && !isset($seen[$cursor])) {
$seen[$cursor] = true;
if (in_array($method, $this->blockedMethods[$cursor] ?? [])) {
$this->throwMethodError($obj, $method);
}
$cursor = $this->blockedForwarders[$cursor] ?? null;
}
}
}
/**
* Casts an object to a sandbox-safe proxy before a method is called on it in a template.
* Used by the custom GetAttrNode to neutralise callable-passthrough on collections and
* paginators (their higher-order methods would otherwise execute arbitrary callables).
*
* @param mixed $object
* @return mixed
*/
public function castMethodObjectToSafeObject($object)
{
if ($object instanceof Enumerable) {
return new SafeCollection($object);
}
if ($object instanceof AbstractPaginator || $object instanceof AbstractCursorPaginator) {
return new SafePaginator($object);
}
return $object;
}
/**
* @param object $obj
* @param string $method
* @throws SecurityNotAllowedMethodError
*/
protected function throwMethodError($obj, $method): void
{
$class = get_class($obj);
throw new SecurityNotAllowedMethodError(sprintf('Calling "%s" method on a "%s" object is blocked.', $method, $class), $class, $method);
}
}

View File

@@ -0,0 +1,38 @@
<?php namespace System\Twig;
use Twig\Compiler;
use Twig\Node\Node;
use Twig\Node\NodeOutputInterface;
/**
* Represents a spaceless node.
*
* It removes spaces between HTML tags.
*
* Removed in Twig 3.0, but retained in Winter CMS for compatibility.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class SpacelessNode extends Node implements NodeOutputInterface
{
public function __construct(Node $body, int $lineno, string $tag = 'spaceless')
{
parent::__construct(['body' => $body], [], $lineno, $tag);
}
public function compile(Compiler $compiler)
{
$compiler
->addDebugInfo($this)
;
if ($compiler->getEnvironment()->isDebug()) {
$compiler->write("ob_start();\n");
} else {
$compiler->write("ob_start(function () { return ''; });\n");
}
$compiler
->subcompile($this->getNode('body'))
->write("echo trim(preg_replace('/>\s+</', '><', ob_get_clean()));\n")
;
}
}

View File

@@ -0,0 +1,29 @@
<?php namespace System\Twig;
use Twig\Token;
use Twig\TokenParser\AbstractTokenParser;
final class SpacelessTokenParser extends AbstractTokenParser
{
public function parse(Token $token)
{
$stream = $this->parser->getStream();
$lineno = $token->getLine();
$stream->expect(/* Token::BLOCK_END_TYPE */ 3);
$body = $this->parser->subparse([$this, 'decideSpacelessEnd'], true);
$stream->expect(/* Token::BLOCK_END_TYPE */ 3);
return new SpacelessNode($body, $lineno, $this->getTag());
}
public function decideSpacelessEnd(Token $token)
{
return $token->test('endspaceless');
}
public function getTag()
{
return 'spaceless';
}
}

View File

@@ -0,0 +1,165 @@
<?php namespace System\Twig\Node;
use System\Twig\SecurityPolicy;
use Twig\Compiler;
use Twig\Environment;
use Twig\Extension\CoreExtension;
use Twig\Extension\SandboxExtension;
use Twig\Node\Expression\GetAttrExpression;
use Twig\Node\Node;
use Twig\Source;
use Twig\Template;
/**
* GetAttrNode replaces Twig's GetAttrExpression so that, in sandbox mode, the object a method
* is called on is first cast to a sandbox-safe proxy (see SecurityPolicy::castMethodObjectToSafeObject).
* This is the only way to neutralise callable-passthrough on collections/paginators, because the
* security policy is not given method arguments.
*
* The compile() logic is a faithful copy of the parent (Twig 3.22) so array access, the sandbox
* ARRAY_LIKE_CLASSES fast-path, strict-variable handling and the `defined` test all behave
* identically; only the getAttribute call target is swapped for customGetAttribute().
*
* @package winter\wn-system-module
*/
class GetAttrNode extends GetAttrExpression
{
/**
* @inheritDoc
*/
public function __construct(array $nodes = [], array $attributes = [], int $lineno = 0)
{
// Skip GetAttrExpression::__construct() (it requires positional child nodes); the node
// visitor supplies fully-formed $nodes/$attributes copied from the original expression.
Node::__construct($nodes, $attributes, $lineno);
}
/**
* @inheritDoc
*/
public function compile(Compiler $compiler): void
{
$env = $compiler->getEnvironment();
$arrayAccessSandbox = false;
// optimize array calls
if (
$this->getAttribute('optimizable')
&& (!$env->isStrictVariables() || $this->getAttribute('ignore_strict_check'))
&& !$this->isDefinedTestEnabled()
&& Template::ARRAY_CALL === $this->getAttribute('type')
) {
$var = '$'.$compiler->getVarName();
$compiler
->raw('(('.$var.' = ')
->subcompile($this->getNode('node'))
->raw(') && is_array(')
->raw($var);
if (!$env->hasExtension(SandboxExtension::class)) {
$compiler
->raw(') || ')
->raw($var)
->raw(' instanceof ArrayAccess ? (')
->raw($var)
->raw('[')
->subcompile($this->getNode('attribute'))
->raw('] ?? null) : null)')
;
return;
}
$arrayAccessSandbox = true;
$compiler
->raw(') || ')
->raw($var)
->raw(' instanceof ArrayAccess && in_array(')
->raw($var.'::class')
->raw(', \\Twig\\Extension\\CoreExtension::ARRAY_LIKE_CLASSES, true) ? (')
->raw($var)
->raw('[')
->subcompile($this->getNode('attribute'))
->raw('] ?? null) : ')
;
}
// Different from the parent: call our customGetAttribute() so the receiver is cast first.
$compiler->raw(static::class.'::customGetAttribute($this->env, $this->source, ');
if ($this->getAttribute('ignore_strict_check')) {
$this->getNode('node')->setAttribute('ignore_strict_check', true);
}
$compiler
->subcompile($this->getNode('node'))
->raw(', ')
->subcompile($this->getNode('attribute'))
;
if ($this->hasNode('arguments')) {
$compiler->raw(', ')->subcompile($this->getNode('arguments'));
} else {
$compiler->raw(', []');
}
$compiler->raw(', ')
->repr($this->getAttribute('type'))
->raw(', ')->repr($this->isDefinedTestEnabled())
->raw(', ')->repr($this->getAttribute('ignore_strict_check'))
->raw(', ')->repr($env->hasExtension(SandboxExtension::class))
->raw(', ')->repr($this->getNode('node')->getTemplateLine())
->raw(')')
;
if ($arrayAccessSandbox) {
$compiler->raw(')');
}
}
/**
* customGetAttribute wraps CoreExtension::getAttribute, casting the object to a safe proxy
* before a method-invoking access when the sandbox is active.
*
* The cast fires on METHOD_CALL and on any access that carries arguments (so the built-in
* `attribute()` function, which compiles to an ANY_CALL, is also covered). Property/relation
* access with no arguments is left untouched, so collections stay iterable.
*/
public static function customGetAttribute(
Environment $env,
Source $source,
$object,
$item,
array $arguments = [],
$type = /* Template::ANY_CALL */ 'any',
$isDefinedTest = false,
$ignoreStrictCheck = false,
$sandboxed = false,
int $lineno = -1
) {
if (
$sandboxed
&& ($type === Template::METHOD_CALL || $arguments)
&& $env->hasExtension(SandboxExtension::class)
) {
$policy = $env->getExtension(SandboxExtension::class)->getSecurityPolicy();
if ($policy instanceof SecurityPolicy) {
$object = $policy->castMethodObjectToSafeObject($object);
}
}
return CoreExtension::getAttribute(
$env,
$source,
$object,
$item,
$arguments,
$type,
$isDefinedTest,
$ignoreStrictCheck,
$sandboxed,
$lineno
);
}
}

View File

@@ -0,0 +1,165 @@
<?php namespace System\Twig\SecurityPolicy;
use ArrayAccess;
use Countable;
use IteratorAggregate;
use Traversable;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Jsonable;
use Illuminate\Support\Enumerable;
use Illuminate\Support\Traits\ForwardsCalls;
/**
* SafeCollection is a collection proxy that is safe to use in a Twig sandbox.
*
* Collections are handed to templates everywhere, and their higher-order methods
* (map, each, filter, reduce, ...) execute arbitrary callables `things.map('system')`
* would run `system()`. Twig's security policy cannot inspect method *arguments*, so
* instead the receiver is cast to this proxy (by the custom GetAttrNode) before the call,
* and every callable argument is nulled out before being forwarded. Callables are unusable
* in Twig anyway, so nothing legitimate is lost.
*
* @package winter\wn-system-module
*/
class SafeCollection implements ArrayAccess, Countable, IteratorAggregate, Arrayable, Jsonable
{
use ForwardsCalls;
/**
* @var Enumerable The wrapped collection (Collection or LazyCollection).
*/
protected $collection;
/**
* @var string[] Methods where a string argument is an attribute/key name (not a callback).
* For these, string values are preserved; non-string callables are still stripped.
* Safe because Laravel's useAsCallable() never treats a string as a callback.
*/
protected $hybridCallableArgs = [
'contains',
'containsstrict',
'doesntcontain',
'groupby',
'keyby',
'implode',
'search',
'sortby',
'sortbydesc',
'unique',
'duplicates',
'partition',
];
/**
* @var string[] Methods that instantiate arbitrary classes or dispatch statically from a
* string argument (not caught by is_callable stripping), so they are blocked outright.
*/
protected $blockedMethods = [
'mapinto',
'pipeinto',
'toresourcecollection',
];
/**
* Constructor
*/
public function __construct(Enumerable $collection)
{
$this->collection = $collection;
}
/**
* Forward all other calls to the collection, stripping callable arguments first.
*/
public function __call($method, $parameters)
{
if (in_array(strtolower($method), $this->blockedMethods)) {
return $this;
}
$normalized = strtolower($method);
foreach ($parameters as &$param) {
$param = $this->stripCallables($param, $normalized);
}
unset($param);
return $this->forwardCallTo($this->collection, $method, $parameters);
}
/**
* Recursively null out any callable value at any depth. Hybrid methods keep string
* values (used as attribute names) but still drop non-string callables.
*/
protected function stripCallables($value, string $method)
{
if (is_array($value)) {
foreach ($value as $key => $item) {
$value[$key] = $this->stripCallables($item, $method);
}
return $value;
}
if (
is_callable($value) &&
(!in_array($method, $this->hybridCallableArgs) || !is_string($value))
) {
return null;
}
return $value;
}
public function getIterator(): Traversable
{
return $this->collection->getIterator();
}
public function offsetExists($offset): bool
{
return $this->collection instanceof ArrayAccess
? $this->collection->offsetExists($offset)
: false;
}
#[\ReturnTypeWillChange]
public function offsetGet($offset)
{
return $this->collection instanceof ArrayAccess
? $this->collection->offsetGet($offset)
: null;
}
public function offsetSet($offset, $value): void
{
if ($this->collection instanceof ArrayAccess) {
$this->collection->offsetSet($offset, $value);
}
}
public function offsetUnset($offset): void
{
if ($this->collection instanceof ArrayAccess) {
$this->collection->offsetUnset($offset);
}
}
public function count(): int
{
return $this->collection->count();
}
public function toArray()
{
return $this->collection->toArray();
}
public function toJson($options = 0)
{
return $this->collection->toJson($options);
}
public function __toString(): string
{
return $this->collection->toJson();
}
}

View File

@@ -0,0 +1,100 @@
<?php namespace System\Twig\SecurityPolicy;
use ArrayAccess;
use Countable;
use IteratorAggregate;
use Traversable;
use Illuminate\Support\Traits\ForwardsCalls;
/**
* SafePaginator is a paginator proxy that is safe to use in a Twig sandbox.
*
* Paginators expose `through(callable)` (on both AbstractPaginator and
* AbstractCursorPaginator), which executes an arbitrary callable over the items. This proxy
* forwards every method to the wrapped paginator but strips callable arguments first, exactly
* like SafeCollection. All the rendering/navigation methods (render, links, currentPage,
* total, items, url, ...) keep working because they take no callables.
*
* @package winter\wn-system-module
*/
class SafePaginator implements ArrayAccess, Countable, IteratorAggregate
{
use ForwardsCalls;
/**
* @var \Illuminate\Pagination\AbstractPaginator|\Illuminate\Pagination\AbstractCursorPaginator
*/
protected $paginator;
/**
* Constructor
*/
public function __construct($paginator)
{
$this->paginator = $paginator;
}
/**
* Forward all calls to the paginator, stripping callable arguments first.
*/
public function __call($method, $parameters)
{
foreach ($parameters as &$param) {
$param = $this->stripCallables($param);
}
unset($param);
return $this->forwardCallTo($this->paginator, $method, $parameters);
}
/**
* Recursively null out any callable value at any depth.
*/
protected function stripCallables($value)
{
if (is_array($value)) {
foreach ($value as $key => $item) {
$value[$key] = $this->stripCallables($item);
}
return $value;
}
return is_callable($value) ? null : $value;
}
public function getIterator(): Traversable
{
return $this->paginator->getIterator();
}
public function offsetExists($offset): bool
{
return $this->paginator->offsetExists($offset);
}
#[\ReturnTypeWillChange]
public function offsetGet($offset)
{
return $this->paginator->offsetGet($offset);
}
public function offsetSet($offset, $value): void
{
$this->paginator->offsetSet($offset, $value);
}
public function offsetUnset($offset): void
{
$this->paginator->offsetUnset($offset);
}
public function count(): int
{
return $this->paginator->count();
}
public function __toString(): string
{
return (string) $this->paginator->render();
}
}