555 lines
20 KiB
PHP
555 lines
20 KiB
PHP
<?php
|
|
/*
|
|
* Copyright (C) 2025 Xibo Signage Ltd
|
|
*
|
|
* Xibo - Digital Signage - https://xibosignage.com
|
|
*
|
|
* This file is part of Xibo.
|
|
*
|
|
* Xibo is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* any later version.
|
|
*
|
|
* Xibo is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
namespace Xibo\Widget\Definition;
|
|
|
|
use Carbon\Carbon;
|
|
use Carbon\CarbonInterval;
|
|
use Illuminate\Support\Str;
|
|
use Respect\Validation\Validator as v;
|
|
use Xibo\Support\Exception\InvalidArgumentException;
|
|
use Xibo\Support\Exception\ValueTooLargeException;
|
|
use Xibo\Support\Sanitizer\SanitizerInterface;
|
|
|
|
/**
|
|
* A Property
|
|
* @SWG\Definition()
|
|
*/
|
|
class Property implements \JsonSerializable
|
|
{
|
|
/**
|
|
* @SWG\Property(description="ID, saved as a widget option")
|
|
* @var string
|
|
*/
|
|
public $id;
|
|
|
|
/**
|
|
* @SWG\Property(description="Type, determines the field type")
|
|
* @var string
|
|
*/
|
|
public $type;
|
|
|
|
/**
|
|
* @SWG\Property(description="Title: shown in the property panel")
|
|
* @var string
|
|
*/
|
|
public $title;
|
|
|
|
/**
|
|
* @SWG\Property(description="Help Text: shown in the property panel")
|
|
* @var string
|
|
*/
|
|
public $helpText;
|
|
|
|
/** @var \Xibo\Widget\Definition\Rule */
|
|
public $validation;
|
|
|
|
/**
|
|
* @SWG\Property()
|
|
* @var string An optional default value
|
|
*/
|
|
public $default;
|
|
|
|
/** @var \Xibo\Widget\Definition\Option[] */
|
|
public $options;
|
|
|
|
/** @var \Xibo\Widget\Definition\Test[] */
|
|
public $visibility = [];
|
|
|
|
/** @var string The element variant */
|
|
public $variant;
|
|
|
|
/** @var string The data format */
|
|
public $format;
|
|
|
|
/** @var bool Should library refs be permitted in the value? */
|
|
public $allowLibraryRefs = false;
|
|
|
|
/** @var bool Should asset refs be permitted in the value? */
|
|
public $allowAssetRefs = false;
|
|
|
|
/** @var bool Should translations be parsed in the value? */
|
|
public $parseTranslations = false;
|
|
|
|
/** @var bool Should the property be included in the XLF? */
|
|
public $includeInXlf = false;
|
|
|
|
/** @var bool Should the property be sent into Elements */
|
|
public $sendToElements = false;
|
|
|
|
/** @var bool Should the default value be written out to widget options */
|
|
public $saveDefault = false;
|
|
|
|
/** @var \Xibo\Widget\Definition\PlayerCompatibility */
|
|
public $playerCompatibility;
|
|
|
|
/** @var string HTML to populate a custom popover to be shown next to the input */
|
|
public $customPopOver;
|
|
|
|
/** @var string HTML selector of the element that this property depends on */
|
|
public $dependsOn;
|
|
|
|
/** @var string ID of the target element */
|
|
public $target;
|
|
|
|
/** @var string The mode of the property */
|
|
public $mode;
|
|
|
|
/** @var string The group ID of the property */
|
|
public $propertyGroupId;
|
|
|
|
/** @var mixed The value assigned to this property. This is set from widget options, or settings, never via XML */
|
|
public $value;
|
|
|
|
/** @inheritDoc */
|
|
public function jsonSerialize(): array
|
|
{
|
|
return [
|
|
'id' => $this->id,
|
|
'value' => $this->value,
|
|
'type' => $this->type,
|
|
'variant' => $this->variant,
|
|
'format' => $this->format,
|
|
'title' => $this->title,
|
|
'mode' => $this->mode,
|
|
'target' => $this->target,
|
|
'propertyGroupId' => $this->propertyGroupId,
|
|
'helpText' => $this->helpText,
|
|
'validation' => $this->validation,
|
|
'default' => $this->default,
|
|
'options' => $this->options,
|
|
'customPopOver' => $this->customPopOver,
|
|
'playerCompatibility' => $this->playerCompatibility,
|
|
'visibility' => $this->visibility,
|
|
'allowLibraryRefs' => $this->allowLibraryRefs,
|
|
'allowAssetRefs' => $this->allowAssetRefs,
|
|
'parseTranslations' => $this->parseTranslations,
|
|
'saveDefault' => $this->saveDefault,
|
|
'dependsOn' => $this->dependsOn,
|
|
'sendToElements' => $this->sendToElements,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Add an option
|
|
* @param string $name
|
|
* @param string $image
|
|
* @param array $set
|
|
* @param string $title
|
|
* @return $this
|
|
*/
|
|
public function addOption(string $name, string $image, array $set, string $title): Property
|
|
{
|
|
$option = new Option();
|
|
$option->name = $name;
|
|
$option->image = $image;
|
|
$option->set = $set;
|
|
$option->title = __($title);
|
|
$this->options[] = $option;
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Add a visibility test
|
|
* @param string $type
|
|
* @param string|null $message
|
|
* @param array $conditions
|
|
* @return $this
|
|
*/
|
|
public function addVisibilityTest(string $type, ?string $message, array $conditions): Property
|
|
{
|
|
$this->visibility[] = $this->parseTest($type, $message, $conditions);
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @param \Xibo\Support\Sanitizer\SanitizerInterface $params
|
|
* @param string|null $key
|
|
* @return \Xibo\Widget\Definition\Property
|
|
* @throws \Xibo\Support\Exception\InvalidArgumentException
|
|
*/
|
|
public function setDefaultByType(SanitizerInterface $params, ?string $key = null): Property
|
|
{
|
|
$this->default = $this->getByType($params, $key);
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @param SanitizerInterface $params
|
|
* @param string|null $key
|
|
* @param bool $ignoreDefault
|
|
* @return Property
|
|
* @throws InvalidArgumentException
|
|
*/
|
|
public function setValueByType(
|
|
SanitizerInterface $params,
|
|
?string $key = null,
|
|
bool $ignoreDefault = false
|
|
): Property {
|
|
$value = $this->getByType($params, $key);
|
|
if ($value !== $this->default || $ignoreDefault || $this->saveDefault) {
|
|
$this->value = $value;
|
|
}
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @param array $properties A key/value array of all properties for this entity (be it module or template)
|
|
* @param string $stage What stage are we at?
|
|
* @return Property
|
|
* @throws InvalidArgumentException
|
|
* @throws ValueTooLargeException
|
|
*/
|
|
public function validate(array $properties, string $stage): Property
|
|
{
|
|
if (!empty($this->value) && strlen($this->value) > 67108864) {
|
|
throw new ValueTooLargeException(sprintf(__('Value too large for %s'), $this->title), $this->id);
|
|
}
|
|
|
|
// Skip if no validation.
|
|
if ($this->validation === null
|
|
|| ($stage === 'save' && !$this->validation->onSave)
|
|
|| ($stage === 'status' && !$this->validation->onStatus)
|
|
) {
|
|
return $this;
|
|
}
|
|
|
|
foreach ($this->validation->tests as $test) {
|
|
// We have a test, evaulate its conditions.
|
|
$exceptions = [];
|
|
|
|
foreach ($test->conditions as $condition) {
|
|
try {
|
|
// Assume we're testing the field we belong to, and if that's empty use the default value
|
|
$testValue = $this->value ?? $this->default;
|
|
|
|
// What value are we testing against (only used by certain types)
|
|
if (empty($condition->field)) {
|
|
$valueToTestAgainst = $condition->value;
|
|
} else {
|
|
// If a field and a condition value is provided, test against those, ignoring my own field value
|
|
if (!empty($condition->value)) {
|
|
$testValue = $condition->value;
|
|
}
|
|
|
|
$valueToTestAgainst = $properties[$condition->field] ?? null;
|
|
}
|
|
|
|
// Do we have a message
|
|
$message = empty($test->message) ? null : __($test->message);
|
|
|
|
switch ($condition->type) {
|
|
case 'required':
|
|
// We will accept the default value here
|
|
if (empty($testValue) && empty($this->default)) {
|
|
throw new InvalidArgumentException(
|
|
$message ?? sprintf(__('Missing required property %s'), $this->title),
|
|
$this->id
|
|
);
|
|
}
|
|
break;
|
|
|
|
case 'uri':
|
|
if (!empty($testValue)
|
|
&& !v::url()->validate($testValue)
|
|
) {
|
|
throw new InvalidArgumentException(
|
|
$message ?? sprintf(__('%s must be a valid URI'), $this->title),
|
|
$this->id
|
|
);
|
|
}
|
|
break;
|
|
|
|
case 'windowsPath':
|
|
// Ensure the path is a valid Windows file path ending in a file, not a directory
|
|
$windowsPathRegex = '/^(?P<Root>[A-Za-z]:)(?P<Relative>(?:\\\\[^<>:"\/\\\\|?*\r\n]+)+)(?P<File>\\\\[^<>:"\/\\\\|?*\r\n]+)$/';
|
|
|
|
// Check if the test value is not empty and does not match the regular expression
|
|
if (!empty($testValue)
|
|
&& !preg_match($windowsPathRegex, $testValue)
|
|
) {
|
|
// Throw an InvalidArgumentException if the test value is not a valid Windows path
|
|
throw new InvalidArgumentException(
|
|
$message ?? sprintf(__('%s must be a valid Windows path'), $this->title),
|
|
$this->id
|
|
);
|
|
}
|
|
|
|
break;
|
|
|
|
case 'interval':
|
|
if (!empty($testValue)) {
|
|
// Try to create a date interval from it
|
|
$dateInterval = CarbonInterval::createFromDateString($testValue);
|
|
if ($dateInterval === false) {
|
|
throw new InvalidArgumentException(
|
|
// phpcs:ignore Generic.Files.LineLength
|
|
__('That is not a valid date interval, please use natural language such as 1 week'),
|
|
'customInterval'
|
|
);
|
|
}
|
|
|
|
// Use now and add the date interval to it
|
|
$now = Carbon::now();
|
|
$check = $now->copy()->add($dateInterval);
|
|
|
|
if ($now->equalTo($check)) {
|
|
throw new InvalidArgumentException(
|
|
// phpcs:ignore Generic.Files.LineLength
|
|
$message ?? __('That is not a valid date interval, please use natural language such as 1 week'),
|
|
$this->id
|
|
);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'eq':
|
|
if ($testValue != $valueToTestAgainst) {
|
|
throw new InvalidArgumentException(
|
|
$message ?? sprintf(__('%s must equal %s'), $this->title, $valueToTestAgainst),
|
|
$this->id,
|
|
);
|
|
}
|
|
break;
|
|
|
|
case 'neq':
|
|
if ($testValue == $valueToTestAgainst) {
|
|
throw new InvalidArgumentException(
|
|
$message ?? sprintf(__('%s must not equal %s'), $this->title, $valueToTestAgainst),
|
|
$this->id,
|
|
);
|
|
}
|
|
break;
|
|
|
|
case 'contains':
|
|
if (!empty($testValue) && !Str::contains($testValue, $valueToTestAgainst)) {
|
|
throw new InvalidArgumentException(
|
|
$message ?? sprintf(__('%s must contain %s'), $this->title, $valueToTestAgainst),
|
|
$this->id,
|
|
);
|
|
}
|
|
break;
|
|
|
|
case 'ncontains':
|
|
if (!empty($testValue) && Str::contains($testValue, $valueToTestAgainst)) {
|
|
throw new InvalidArgumentException(
|
|
// phpcs:ignore Generic.Files.LineLength
|
|
$message ?? sprintf(__('%s must not contain %s'), $this->title, $valueToTestAgainst),
|
|
$this->id,
|
|
);
|
|
}
|
|
break;
|
|
|
|
case 'lt':
|
|
// Value must be < to the condition value, or field value
|
|
if (!($testValue < $valueToTestAgainst)) {
|
|
throw new InvalidArgumentException(
|
|
// phpcs:ignore Generic.Files.LineLength
|
|
$message ?? sprintf(__('%s must be less than %s'), $this->title, $valueToTestAgainst),
|
|
$this->id
|
|
);
|
|
}
|
|
break;
|
|
|
|
case 'lte':
|
|
// Value must be <= to the condition value, or field value
|
|
if (!($testValue <= $valueToTestAgainst)) {
|
|
throw new InvalidArgumentException(
|
|
// phpcs:ignore Generic.Files.LineLength
|
|
$message ?? sprintf(__('%s must be less than or equal to %s'), $this->title, $valueToTestAgainst),
|
|
$this->id
|
|
);
|
|
}
|
|
break;
|
|
|
|
case 'gte':
|
|
// Value must be >= to the condition value, or field value
|
|
if (!($testValue >= $valueToTestAgainst)) {
|
|
throw new InvalidArgumentException(
|
|
// phpcs:ignore Generic.Files.LineLength
|
|
$message ?? sprintf(__('%s must be greater than or equal to %s'), $this->title, $valueToTestAgainst),
|
|
$this->id
|
|
);
|
|
}
|
|
break;
|
|
|
|
case 'gt':
|
|
// Value must be > to the condition value, or field value
|
|
if (!($testValue > $valueToTestAgainst)) {
|
|
throw new InvalidArgumentException(
|
|
// phpcs:ignore Generic.Files.LineLength
|
|
$message ?? sprintf(__('%s must be greater than %s'), $this->title, $valueToTestAgainst),
|
|
$this->id
|
|
);
|
|
}
|
|
break;
|
|
|
|
default:
|
|
// Nothing to validate
|
|
}
|
|
} catch (InvalidArgumentException $invalidArgumentException) {
|
|
// If we are an AND test, all conditions must pass, so we know already to exception here.
|
|
if ($test->type === 'and') {
|
|
throw $invalidArgumentException;
|
|
}
|
|
|
|
// We're an OR
|
|
$exceptions[] = $invalidArgumentException;
|
|
}
|
|
}
|
|
|
|
// If we are an OR then make sure all conditions have failed.
|
|
$countOfFailures = count($exceptions);
|
|
if ($test->type === 'or' && $countOfFailures === count($test->conditions)) {
|
|
throw $exceptions[0];
|
|
}
|
|
}
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @param \Xibo\Support\Sanitizer\SanitizerInterface $params
|
|
* @param string|null $key
|
|
* @return bool|float|int|string|null
|
|
* @throws \Xibo\Support\Exception\InvalidArgumentException
|
|
*/
|
|
private function getByType(SanitizerInterface $params, ?string $key = null)
|
|
{
|
|
$key = $key ?: $this->id;
|
|
|
|
if (!$params->hasParam($key) && $this->type !== 'checkbox') {
|
|
// Clear the stored value and therefore use the default
|
|
return null;
|
|
}
|
|
|
|
// Parse according to the type of field we're expecting
|
|
switch ($this->type) {
|
|
case 'checkbox':
|
|
return $params->getCheckbox($key);
|
|
|
|
case 'integer':
|
|
return $params->getInt($key);
|
|
|
|
case 'number':
|
|
return $params->getDouble($key);
|
|
|
|
case 'dropdown':
|
|
$value = $params->getString($key);
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
|
|
$found = false;
|
|
foreach ($this->options as $option) {
|
|
if ($option->name === $value) {
|
|
$found = true;
|
|
break;
|
|
}
|
|
}
|
|
if ($found) {
|
|
return $value;
|
|
} else {
|
|
throw new InvalidArgumentException(
|
|
sprintf(__('%s is not a valid option'), $value),
|
|
$key
|
|
);
|
|
}
|
|
|
|
case 'code':
|
|
case 'richText':
|
|
return $params->getParam($key);
|
|
case 'text':
|
|
if ($this->variant === 'sql') {
|
|
// Handle raw SQL clauses
|
|
return str_ireplace(Sql::DISALLOWED_KEYWORDS, '', $params->getParam($key));
|
|
} else {
|
|
return $params->getString($key);
|
|
}
|
|
default:
|
|
return $params->getString($key);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply any filters on the data.
|
|
* @return void
|
|
*/
|
|
public function applyFilters(): void
|
|
{
|
|
if ($this->variant === 'uri' || $this->type === 'commandBuilder') {
|
|
$this->value = urlencode($this->value);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reverse filters
|
|
* @return void
|
|
*/
|
|
public function reverseFilters(): void
|
|
{
|
|
$this->value = $this->reverseFiltersOnValue($this->value);
|
|
}
|
|
|
|
/**
|
|
* @param mixed $value
|
|
* @return mixed|string
|
|
*/
|
|
public function reverseFiltersOnValue(mixed $value): mixed
|
|
{
|
|
if (($this->variant === 'uri' || $this->type === 'commandBuilder') && !empty($value)) {
|
|
$value = urldecode($value);
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
/**
|
|
* Should this property be represented with CData
|
|
* @return bool
|
|
*/
|
|
public function isCData(): bool
|
|
{
|
|
return $this->type === 'code' || $this->type === 'richText';
|
|
}
|
|
|
|
/**
|
|
* @param string $type
|
|
* @param string $message
|
|
* @param array $conditions
|
|
* @return Test
|
|
*/
|
|
public function parseTest(string $type, string $message, array $conditions): Test
|
|
{
|
|
$test = new Test();
|
|
$test->type = $type ?: 'and';
|
|
$test->message = $message;
|
|
|
|
foreach ($conditions as $item) {
|
|
$condition = new Condition();
|
|
$condition->type = $item['type'];
|
|
$condition->field = $item['field'];
|
|
$condition->value = $item['value'];
|
|
$test->conditions[] = $condition;
|
|
}
|
|
return $test;
|
|
}
|
|
}
|