Version 5.0.0 beta 9

This commit is contained in:
Neo committed 2025-12-19 17:47:00 -08:00
1 parent 214f649f1d
commit 56979f8ee8
202 files changed
+4403 -1123

No files matched your search

+241
View File
@@ -0,0 +1,241 @@
<?php
declare(strict_types = 1);
namespace DASPRiD\Enum;
use DASPRiD\Enum\Exception\CloneNotSupportedException;
use DASPRiD\Enum\Exception\IllegalArgumentException;
use DASPRiD\Enum\Exception\MismatchException;
use DASPRiD\Enum\Exception\SerializeNotSupportedException;
use DASPRiD\Enum\Exception\UnserializeNotSupportedException;
use ReflectionClass;
abstract class AbstractEnum
{
/**
* @var string
*/
private $name;
/**
* @var int
*/
private $ordinal;
/**
* @var array<string, array<string, static>>
*/
private static $values = [];
/**
* @var array<string, bool>
*/
private static $allValuesLoaded = [];
/**
* @var array<string, array>
*/
private static $constants = [];
/**
* The constructor is private by default to avoid arbitrary enum creation.
*
* When creating your own constructor for a parameterized enum, make sure to declare it as protected, so that
* the static methods are able to construct it. Avoid making it public, as that would allow creation of
* non-singleton enum instances.
*/
private function __construct()
{
}
/**
* Magic getter which forwards all calls to {@see self::valueOf()}.
*
* @return static
*/
final public static function __callStatic(string $name, array $arguments) : self
{
return static::valueOf($name);
}
/**
* Returns an enum with the specified name.
*
* The name must match exactly an identifier used to declare an enum in this type (extraneous whitespace characters
* are not permitted).
*
* @return static
* @throws IllegalArgumentException if the enum has no constant with the specified name
*/
final public static function valueOf(string $name) : self
{
if (isset(self::$values[static::class][$name])) {
return self::$values[static::class][$name];
}
$constants = self::constants();
if (array_key_exists($name, $constants)) {
return self::createValue($name, $constants[$name][0], $constants[$name][1]);
}
throw new IllegalArgumentException(sprintf('No enum constant %s::%s', static::class, $name));
}
/**
* @return static
*/
private static function createValue(string $name, int $ordinal, array $arguments) : self
{
$instance = new static(...$arguments);
$instance->name = $name;
$instance->ordinal = $ordinal;
self::$values[static::class][$name] = $instance;
return $instance;
}
/**
* Obtains all possible types defined by this enum.
*
* @return static[]
*/
final public static function values() : array
{
if (isset(self::$allValuesLoaded[static::class])) {
return self::$values[static::class];
}
if (! isset(self::$values[static::class])) {
self::$values[static::class] = [];
}
foreach (self::constants() as $name => $constant) {
if (array_key_exists($name, self::$values[static::class])) {
continue;
}
static::createValue($name, $constant[0], $constant[1]);
}
uasort(self::$values[static::class], function (self $a, self $b) {
return $a->ordinal() <=> $b->ordinal();
});
self::$allValuesLoaded[static::class] = true;
return self::$values[static::class];
}
private static function constants() : array
{
if (isset(self::$constants[static::class])) {
return self::$constants[static::class];
}
self::$constants[static::class] = [];
$reflectionClass = new ReflectionClass(static::class);
$ordinal = -1;
foreach ($reflectionClass->getReflectionConstants() as $reflectionConstant) {
if (! $reflectionConstant->isProtected()) {
continue;
}
$value = $reflectionConstant->getValue();
self::$constants[static::class][$reflectionConstant->name] = [
++$ordinal,
is_array($value) ? $value : []
];
}
return self::$constants[static::class];
}
/**
* Returns the name of this enum constant, exactly as declared in its enum declaration.
*
* Most programmers should use the {@see self::__toString()} method in preference to this one, as the toString
* method may return a more user-friendly name. This method is designed primarily for use in specialized situations
* where correctness depends on getting the exact name, which will not vary from release to release.
*/
final public function name() : string
{
return $this->name;
}
/**
* Returns the ordinal of this enumeration constant (its position in its enum declaration, where the initial
* constant is assigned an ordinal of zero).
*
* Most programmers will have no use for this method. It is designed for use by sophisticated enum-based data
* structures.
*/
final public function ordinal() : int
{
return $this->ordinal;
}
/**
* Compares this enum with the specified object for order.
*
* Returns negative integer, zero or positive integer as this object is less than, equal to or greater than the
* specified object.
*
* Enums are only comparable to other enums of the same type. The natural order implemented by this method is the
* order in which the constants are declared.
*
* @throws MismatchException if the passed enum is not of the same type
*/
final public function compareTo(self $other) : int
{
if (! $other instanceof static) {
throw new MismatchException(sprintf(
'The passed enum %s is not of the same type as %s',
get_class($other),
static::class
));
}
return $this->ordinal - $other->ordinal;
}
/**
* Forbid cloning enums.
*
* @throws CloneNotSupportedException
*/
final public function __clone()
{
throw new CloneNotSupportedException();
}
/**
* Forbid serializing enums.
*
* @throws SerializeNotSupportedException
*/
final public function __sleep() : array
{
throw new SerializeNotSupportedException();
}
/**
* Forbid unserializing enums.
*
* @throws UnserializeNotSupportedException
*/
final public function __wakeup() : void
{
throw new UnserializeNotSupportedException();
}
/**
* Turns the enum into a string representation.
*
* You may override this method to give a more user-friendly version.
*/
public function __toString() : string
{
return $this->name;
}
}
+385
View File
@@ -0,0 +1,385 @@
<?php
declare(strict_types = 1);
namespace DASPRiD\Enum;
use DASPRiD\Enum\Exception\ExpectationException;
use DASPRiD\Enum\Exception\IllegalArgumentException;
use IteratorAggregate;
use Serializable;
use Traversable;
/**
* A specialized map implementation for use with enum type keys.
*
* All of the keys in an enum map must come from a single enum type that is specified, when the map is created. Enum
* maps are represented internally as arrays. This representation is extremely compact and efficient.
*
* Enum maps are maintained in the natural order of their keys (the order in which the enum constants are declared).
* This is reflected in the iterators returned by the collection views {@see self::getIterator()} and
* {@see self::values()}.
*
* Iterators returned by the collection views are not consistent: They may or may not show the effects of modifications
* to the map that occur while the iteration is in progress.
*/
final class EnumMap implements Serializable, IteratorAggregate
{
/**
* The class name of the key.
*
* @var string
*/
private $keyType;
/**
* The type of the value.
*
* @var string
*/
private $valueType;
/**
* @var bool
*/
private $allowNullValues;
/**
* All of the constants comprising the enum, cached for performance.
*
* @var array<int, AbstractEnum>
*/
private $keyUniverse;
/**
* Array representation of this map. The ith element is the value to which universe[i] is currently mapped, or null
* if it isn't mapped to anything, or NullValue if it's mapped to null.
*
* @var array<int, mixed>
*/
private $values;
/**
* @var int
*/
private $size = 0;
/**
* Creates a new enum map.
*
* @param string $keyType the type of the keys, must extend AbstractEnum
* @param string $valueType the type of the values
* @param bool $allowNullValues whether to allow null values
* @throws IllegalArgumentException when key type does not extend AbstractEnum
*/
public function __construct(string $keyType, string $valueType, bool $allowNullValues)
{
if (! is_subclass_of($keyType, AbstractEnum::class)) {
throw new IllegalArgumentException(sprintf(
'Class %s does not extend %s',
$keyType,
AbstractEnum::class
));
}
$this->keyType = $keyType;
$this->valueType = $valueType;
$this->allowNullValues = $allowNullValues;
$this->keyUniverse = $keyType::values();
$this->values = array_fill(0, count($this->keyUniverse), null);
}
public function __serialize(): array
{
$values = [];
foreach ($this->values as $ordinal => $value) {
if (null === $value) {
continue;
}
$values[$ordinal] = $this->unmaskNull($value);
}
return [
'keyType' => $this->keyType,
'valueType' => $this->valueType,
'allowNullValues' => $this->allowNullValues,
'values' => $values,
];
}
public function __unserialize(array $data): void
{
$this->unserialize(serialize($data));
}
/**
* Checks whether the map types match the supplied ones.
*
* You should call this method when an EnumMap is passed to you and you want to ensure that it's made up of the
* correct types.
*
* @throws ExpectationException when supplied key type mismatches local key type
* @throws ExpectationException when supplied value type mismatches local value type
* @throws ExpectationException when the supplied map allows null values, abut should not
*/
public function expect(string $keyType, string $valueType, bool $allowNullValues) : void
{
if ($keyType !== $this->keyType) {
throw new ExpectationException(sprintf(
'Callee expected an EnumMap with key type %s, but got %s',
$keyType,
$this->keyType
));
}
if ($valueType !== $this->valueType) {
throw new ExpectationException(sprintf(
'Callee expected an EnumMap with value type %s, but got %s',
$keyType,
$this->keyType
));
}
if ($allowNullValues !== $this->allowNullValues) {
throw new ExpectationException(sprintf(
'Callee expected an EnumMap with nullable flag %s, but got %s',
($allowNullValues ? 'true' : 'false'),
($this->allowNullValues ? 'true' : 'false')
));
}
}
/**
* Returns the number of key-value mappings in this map.
*/
public function size() : int
{
return $this->size;
}
/**
* Returns true if this map maps one or more keys to the specified value.
*/
public function containsValue($value) : bool
{
return in_array($this->maskNull($value), $this->values, true);
}
/**
* Returns true if this map contains a mapping for the specified key.
*/
public function containsKey(AbstractEnum $key) : bool
{
$this->checkKeyType($key);
return null !== $this->values[$key->ordinal()];
}
/**
* Returns the value to which the specified key is mapped, or null if this map contains no mapping for the key.
*
* More formally, if this map contains a mapping from a key to a value, then this method returns the value;
* otherwise it returns null (there can be at most one such mapping).
*
* A return value of null does not necessarily indicate that the map contains no mapping for the key; it's also
* possible that hte map explicitly maps the key to null. The {@see self::containsKey()} operation may be used to
* distinguish these two cases.
*
* @return mixed
*/
public function get(AbstractEnum $key)
{
$this->checkKeyType($key);
return $this->unmaskNull($this->values[$key->ordinal()]);
}
/**
* Associates the specified value with the specified key in this map.
*
* If the map previously contained a mapping for this key, the old value is replaced.
*
* @return mixed the previous value associated with the specified key, or null if there was no mapping for the key.
* (a null return can also indicate that the map previously associated null with the specified key.)
* @throws IllegalArgumentException when the passed values does not match the internal value type
*/
public function put(AbstractEnum $key, $value)
{
$this->checkKeyType($key);
if (! $this->isValidValue($value)) {
throw new IllegalArgumentException(sprintf('Value is not of type %s', $this->valueType));
}
$index = $key->ordinal();
$oldValue = $this->values[$index];
$this->values[$index] = $this->maskNull($value);
if (null === $oldValue) {
++$this->size;
}
return $this->unmaskNull($oldValue);
}
/**
* Removes the mapping for this key frm this map if present.
*
* @return mixed the previous value associated with the specified key, or null if there was no mapping for the key.
* (a null return can also indicate that the map previously associated null with the specified key.)
*/
public function remove(AbstractEnum $key)
{
$this->checkKeyType($key);
$index = $key->ordinal();
$oldValue = $this->values[$index];
$this->values[$index] = null;
if (null !== $oldValue) {
--$this->size;
}
return $this->unmaskNull($oldValue);
}
/**
* Removes all mappings from this map.
*/
public function clear() : void
{
$this->values = array_fill(0, count($this->keyUniverse), null);
$this->size = 0;
}
/**
* Compares the specified map with this map for quality.
*
* Returns true if the two maps represent the same mappings.
*/
public function equals(self $other) : bool
{
if ($this === $other) {
return true;
}
if ($this->size !== $other->size) {
return false;
}
return $this->values === $other->values;
}
/**
* Returns the values contained in this map.
*
* The array will contain the values in the order their corresponding keys appear in the map, which is their natural
* order (the order in which the num constants are declared).
*/
public function values() : array
{
return array_values(array_map(function ($value) {
return $this->unmaskNull($value);
}, array_filter($this->values, function ($value) : bool {
return null !== $value;
})));
}
public function serialize() : string
{
return serialize($this->__serialize());
}
public function unserialize($serialized) : void
{
$data = unserialize($serialized);
$this->__construct($data['keyType'], $data['valueType'], $data['allowNullValues']);
foreach ($this->keyUniverse as $key) {
if (array_key_exists($key->ordinal(), $data['values'])) {
$this->put($key, $data['values'][$key->ordinal()]);
}
}
}
public function getIterator() : Traversable
{
foreach ($this->keyUniverse as $key) {
if (null === $this->values[$key->ordinal()]) {
continue;
}
yield $key => $this->unmaskNull($this->values[$key->ordinal()]);
}
}
private function maskNull($value)
{
if (null === $value) {
return NullValue::instance();
}
return $value;
}
private function unmaskNull($value)
{
if ($value instanceof NullValue) {
return null;
}
return $value;
}
/**
* @throws IllegalArgumentException when the passed key does not match the internal key type
*/
private function checkKeyType(AbstractEnum $key) : void
{
if (get_class($key) !== $this->keyType) {
throw new IllegalArgumentException(sprintf(
'Object of type %s is not the same type as %s',
get_class($key),
$this->keyType
));
}
}
private function isValidValue($value) : bool
{
if (null === $value) {
if ($this->allowNullValues) {
return true;
}
return false;
}
switch ($this->valueType) {
case 'mixed':
return true;
case 'bool':
case 'boolean':
return is_bool($value);
case 'int':
case 'integer':
return is_int($value);
case 'float':
case 'double':
return is_float($value);
case 'string':
return is_string($value);
case 'object':
return is_object($value);
case 'array':
return is_array($value);
}
return $value instanceof $this->valueType;
}
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types = 1);
namespace DASPRiD\Enum\Exception;
use Exception;
final class CloneNotSupportedException extends Exception implements ExceptionInterface
{
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types = 1);
namespace DASPRiD\Enum\Exception;
use Throwable;
interface ExceptionInterface extends Throwable
{
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types = 1);
namespace DASPRiD\Enum\Exception;
use Exception;
final class ExpectationException extends Exception implements ExceptionInterface
{
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types = 1);
namespace DASPRiD\Enum\Exception;
use Exception;
final class IllegalArgumentException extends Exception implements ExceptionInterface
{
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types = 1);
namespace DASPRiD\Enum\Exception;
use Exception;
final class MismatchException extends Exception implements ExceptionInterface
{
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types = 1);
namespace DASPRiD\Enum\Exception;
use Exception;
final class SerializeNotSupportedException extends Exception implements ExceptionInterface
{
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types = 1);
namespace DASPRiD\Enum\Exception;
use Exception;
final class UnserializeNotSupportedException extends Exception implements ExceptionInterface
{
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types = 1);
namespace DASPRiD\Enum;
use DASPRiD\Enum\Exception\CloneNotSupportedException;
use DASPRiD\Enum\Exception\SerializeNotSupportedException;
use DASPRiD\Enum\Exception\UnserializeNotSupportedException;
final class NullValue
{
/**
* @var self
*/
private static $instance;
private function __construct()
{
}
public static function instance() : self
{
return self::$instance ?: self::$instance = new self();
}
/**
* Forbid cloning enums.
*
* @throws CloneNotSupportedException
*/
final public function __clone()
{
throw new CloneNotSupportedException();
}
/**
* Forbid serializing enums.
*
* @throws SerializeNotSupportedException
*/
final public function __sleep() : array
{
throw new SerializeNotSupportedException();
}
/**
* Forbid unserializing enums.
*
* @throws UnserializeNotSupportedException
*/
final public function __wakeup() : void
{
throw new UnserializeNotSupportedException();
}
}
+5 -10
View File
@@ -479,7 +479,7 @@ abstract class Comment extends Content
if ( IPS::classUsesTrait( $this, 'IPS\Content\Anonymous' ) and isset( $item::$databaseColumnMap['last_comment_anon'] ) and isset( static::$databaseColumnMap['is_anon'] ) )
{
$lastCommentAnon = $item::$databaseColumnMap['last_comment_anon'];
$item->$lastCommentAnon = $this->mapped('is_anon');
$item->$lastCommentAnon = (int) $this->mapped('is_anon');
}
$item->save();
@@ -1035,11 +1035,6 @@ abstract class Comment extends Content
{
Index::i()->index( $this );
}
if( Content\Search\SearchContent::isSearchable( $item ) )
{
$item->clearCachedStatistics();
}
}
/* Send the content items URL to IndexNow */
@@ -1188,7 +1183,7 @@ abstract class Comment extends Content
if ( $item AND IPS::classUsesTrait( $this->item(), 'IPS\Content\Statistics' ) )
{
$item->clearCachedStatistics();
$item->clearCachedStatistics( TRUE );
}
/* Delete any notifications telling people about this */
@@ -1883,7 +1878,7 @@ abstract class Comment extends Content
}
}
if ( ! Output::i()->reduceLinks() )
if ( ! Output::i()->reduceLinks() and $this->canShare() )
{
if ( $this->mapped( 'first' ) )
{
@@ -1921,7 +1916,7 @@ abstract class Comment extends Content
}
}
if( $this->canEdit($member ) or (IPS::classUsesTrait( $this, "\\IPS\\Content\\Hideable" ) and $this->canHide( $member )) or $this->canSplit( $member ) )
if( $this->canEdit( $member ) or ( IPS::classUsesTrait( $this, "\\IPS\\Content\\Hideable" ) and $this->canHide( $member ) ) or $this->canSplit( $member ) )
{
$menu->addSeparator();
}
@@ -1965,7 +1960,7 @@ abstract class Comment extends Content
if( $this->canUnhide($member) )
{
$menu->add( new ContentMenuLink( url: $this->url( 'unhide' )->csrf(), languageString: 'unhide', dataAttributes: [
$menu->add( new ContentMenuLink( url: $this->url( 'unhide' )->csrf(), languageString: ( $this->hidden() === 1 ? 'approve' : 'unhide' ), dataAttributes: [
'data-ipsDialog-destructOnClose' => 'true'
] ) );
}
+4 -2
View File
@@ -1475,8 +1475,10 @@ class Controller extends CoverPhotoController
{
$options[ $type->id ] = $type->_title;
}
$form->add( new Radio( 'report_type', NULL, FALSE, array( 'options' => $options ) ) );
if ( count( $options ) > 1 )
{
$form->add( new Radio( 'report_type', NULL, FALSE, ['options' => $options] ) );
}
}
$form->add( new Editor( 'report_message', NULL, FALSE, array( 'app' => 'core', 'key' => 'Reports', 'autoSaveKey' => "report-{$class::$application}-{$class::$module}-{$item->$idColumn}", 'minimize' => Request::i()->isAjax() ? 'report_message_placeholder' : NULL ) ) );
+6
View File
@@ -42,6 +42,12 @@ trait Featurable
return false;
}
/* Skip for hidden content */
if( $this->hidden() !== 0 )
{
return false;
}
/* Is this the first comment in an item that requires first comments? */
if ( $this instanceof Comment )
{
+4 -4
View File
@@ -231,7 +231,7 @@ trait Hideable
/* Restoring as hidden? */
if ( $hidden )
{
$column = static::$databaseColumnMap['hidden'] ?? static::$databaseColumnId['approved'];
$column = static::$databaseColumnMap['hidden'] ?? static::$databaseColumnMap['approved'];
$this->$column = -1;
}
else
@@ -461,7 +461,7 @@ trait Hideable
{
try
{
$item->clearCachedStatistics();
$item->clearCachedStatistics( TRUE );
}
catch ( Exception )
{
@@ -1354,7 +1354,7 @@ trait Hideable
return false;
}
if ( $this->hidden() === -1 )
if ( $this->hidden() <> 0 )
{
return FALSE;
}
@@ -1381,7 +1381,7 @@ trait Hideable
return false;
}
if ( !$this->hidden() )
if ( $this->hidden() === 0 )
{
return FALSE;
}
+3 -3
View File
@@ -832,7 +832,7 @@ abstract class Item extends Content
$return['guest_name'] = new Text( 'guest_name', NULL, FALSE, array( 'minLength' => Settings::i()->min_user_name_length, 'maxLength' => Settings::i()->max_user_name_length, 'placeholder' => Member::loggedIn()->language()->addToStack('comment_guest_name'), 'htmlAutocomplete' => "username" ), NULL, NULL, NULL, 'guest_name' );
}
}
if ( Settings::i()->bot_antispam_type !== 'none' and Settings::i()->guest_captcha )
if ( Settings::i()->bot_antispam_type !== 'none' )
{
$return['captcha'] = new Captcha;
}
@@ -4871,7 +4871,7 @@ abstract class Item extends Content
$return['guest_name'] = new Text( 'guest_name', NULL, FALSE, array( 'minLength' => Settings::i()->min_user_name_length, 'maxLength' => Settings::i()->max_user_name_length, 'placeholder' => Member::loggedIn()->language()->addToStack('comment_guest_name') ) );
}
}
if ( Settings::i()->bot_antispam_type !== 'none' and Settings::i()->guest_captcha )
if ( Settings::i()->bot_antispam_type !== 'none' )
{
$return['captcha'] = new Captcha;
}
@@ -5033,7 +5033,7 @@ abstract class Item extends Content
$form->add( new Text( 'guest_name', NULL, FALSE, array( 'minLength' => Settings::i()->min_user_name_length, 'maxLength' => Settings::i()->max_user_name_length, 'placeholder' => Member::loggedIn()->language()->addToStack('comment_guest_name') ) ) );
}
}
if ( Settings::i()->bot_antispam_type !== 'none' and Settings::i()->guest_captcha )
if ( Settings::i()->bot_antispam_type !== 'none' )
{
$form->add( new Captcha );
}
+13
View File
@@ -68,6 +68,13 @@ trait ItemTopic
*/
abstract function getForumId() : int;
/**
* Determine if the topic sync is enabled
*
* @return bool
*/
abstract function isTopicSyncEnabled() : bool;
/**
* Create/Update Topic
*
@@ -75,6 +82,12 @@ trait ItemTopic
*/
public function syncTopic(): void
{
/* If the topic sync is disabled, stop here */
if( !$this->isTopicSyncEnabled() )
{
return;
}
/* If this item is pending deletion, do nothing */
if( $this->hidden() == -2 )
{
+38
View File
@@ -0,0 +1,38 @@
<?php
/**
* @brief Bluesky share link
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @copyright (c) Invision Power Services, Inc.
* @license https://www.invisioncommunity.com/legal/standards/
* @package Invision Community
* @since 19 November 2024
*/
namespace IPS\Content\ShareServices;
use IPS\Content\ShareServices;
use IPS\Theme;
/* To prevent PHP errors (extending class does not exist) revealing path */
if ( !\defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
/**
* Bluesky share link
*/
class Bluesky extends ShareServices
{
/**
* Return the HTML code to show the share link
*
* @return string
*/
public function __toString(): string
{
return Theme::i()->getTemplate( 'sharelinks', 'core' )->bluesky( urlencode( $this->url ), $this->title ? urlencode( $this->title ) : NULL );
}
}
@@ -36,14 +36,15 @@ abstract class ShareServices
*/
public static function services(): array
{
return array(
return [
'Bluesky' => 'IPS\Content\ShareServices\Bluesky',
'Facebook' => 'IPS\Content\ShareServices\Facebook',
'Linkedin' => 'IPS\Content\ShareServices\Linkedin',
'Pinterest' => 'IPS\Content\ShareServices\Pinterest',
'Reddit' => 'IPS\Content\ShareServices\Reddit',
'Email' => 'IPS\Content\ShareServices\Email',
'X' => 'IPS\Content\ShareServices\X',
);
];
}
/**
+57 -14
View File
@@ -553,13 +553,22 @@ trait Statistics
/**
* Clear any cached stats
*
* @param bool $force Force immediate cache delete
* @return void
* @throws Exception
*/
public function clearCachedStatistics(): void
public function clearCachedStatistics( bool $force=FALSE )
{
$idField = static::$databaseColumnId;
Db::i()->delete( 'core_item_statistics_cache', array( 'cache_class=? and cache_item_id=?', get_class( $this ), $this->$idField ) );
if( $force )
{
Db::i()->delete( 'core_item_statistics_cache', [ 'cache_class=? and cache_item_id=?', \get_class( $this ), $this->$idField ] );
}
else
{
/* Set cache to time out in 10 minutes. */
Db::i()->update( 'core_item_statistics_cache', [ 'cache_added' => ( time() - 172200 ) ], [ 'cache_class=? and cache_item_id=?', \get_class( $this ), $this->$idField ] );
}
}
/**
@@ -728,6 +737,11 @@ trait Statistics
*/
public static array|null $cachedActivity = NULL;
/**
* @var bool|null Process has rebuild lock
*/
protected ?bool $hasRebuildLock = NULL;
/**
* Get the cached data
*
@@ -738,28 +752,57 @@ trait Statistics
protected function _getCached( string $key ): mixed
{
$idField = static::$databaseColumnId;
if( $this->hasRebuildLock === FALSE )
{
return [];
}
$class = get_class( $this );
$arrayKey = $class.'.'.$this->$idField;
if ( !isset( static::$cachedActivity[$arrayKey] ) )
$arrayKey = $class . '.' . $this->$idField;
if ( !isset( static::$cachedActivity[ $arrayKey ] ) )
{
try
{
$cache = Db::i()->select( '*', 'core_item_statistics_cache', array( 'cache_class=? and cache_item_id=?', $class, $this->$idField ) )->first();
if ( $cache['cache_added'] > time() - 86400 )
$cache = Db::i()->select( '*', 'core_item_statistics_cache', [ 'cache_class=? and cache_item_id=?', $class, $this->$idField ] )->first();
if ( $cache['cache_added'] > ( time() - 172800 ) )
{
static::$cachedActivity[ $arrayKey ] = json_decode( $cache['cache_contents'], TRUE );
}
else
{
Db::i()->delete( 'core_item_statistics_cache', array( 'cache_class=? and cache_item_id=?', get_class( $this ), $this->$idField ) );
$this->hasRebuildLock = TRUE;
$affectedRows = Db::i()->delete( 'core_item_statistics_cache', [ 'cache_class=? and cache_item_id=?', \get_class( $this ), $this->$idField ] );
if( $affectedRows == 0 )
{
/* We did not delete the row, allow another process to rebuild the cache */
$this->hasRebuildLock = FALSE;
return [];
}
}
}
catch( \UnderflowException $e )
{
/* Try to get the lock for new cache generation */
try
{
Db::i()->insert( 'core_item_statistics_cache', [
'cache_class' => \get_class( $this ),
'cache_item_id' => $this->$idField,
'cache_contents' => '[]',
'cache_added' => time()
] );
$this->hasRebuildLock = TRUE;
}
/* We didn't get the lock, return empty array and allow other process to build cache */
catch( \IPS\Db\Exception $e )
{
$this->hasRebuildLock = FALSE;
return [];
}
}
catch( UnderflowException $e ) { }
}
if ( isset(static::$cachedActivity[ $arrayKey ] ) and isset( static::$cachedActivity[ $arrayKey ][ $key ] ) )
if ( isset( static::$cachedActivity[ $arrayKey ] ) and isset( static::$cachedActivity[ $arrayKey ][ $key ] ) )
{
return static::$cachedActivity[ $arrayKey ][ $key ];
}
@@ -767,10 +810,10 @@ trait Statistics
{
if( !isset( static::$cachedActivity[ $arrayKey ] ) )
{
static::$cachedActivity[ $arrayKey ] = array();
static::$cachedActivity[ $arrayKey ] = [];
}
static::$cachedActivity[ $arrayKey ][ $key ] = array();
static::$cachedActivity[ $arrayKey ][ $key ] = [];
throw new OutOfRangeException;
}
}
@@ -819,8 +862,8 @@ trait Statistics
{
Db::i()->insert( 'core_item_statistics_cache', array(
'cache_class' => get_class( $this ),
'cache_item_id' => str_replace( get_class($this) . '.', '', $key),
'cache_contents' => json_encode( static::$cachedActivity[$key] ),
'cache_item_id' => str_replace( \get_class( $this ) . '.', '', $key ),
'cache_contents' => json_encode( static::$cachedActivity[ $key ] ) ?? '[]',
'cache_added' => time()
), TRUE );
}
+31 -11
View File
@@ -10,9 +10,9 @@
namespace IPS\Email\Outgoing;
/* To prevent PHP errors (extending class does not exist) revealing path */
use IPS\Email;
use IPS\Login;
use IPS\Http\Url;
use IPS\Request;
use IPS\Settings;
use function defined;
@@ -21,6 +21,8 @@ use function stream_socket_enable_crypto;
use const IPS\DEFAULT_REQUEST_TIMEOUT;
use const IPS\VERY_LONG_REQUEST_TIMEOUT;
/* To prevent PHP errors (extending class does not exist) revealing path */
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
@@ -128,15 +130,16 @@ class Smtp extends Email
}
/* HELO/EHLO */
$ehloFqdn = \IPS\Http\Url::internal('')->data['host'];
try
{
$helo = 'EHLO';
$responseCode = $this->_sendCommand('EHLO ' . $this->smtpHost, 250);
$responseCode = $this->_sendCommand( 'EHLO ' . $ehloFqdn, 250 );
}
catch (Exception $e )
{
$helo = 'HELO';
$responseCode = $this->_sendCommand('HELO ' . $this->smtpHost, 250);
$responseCode = $this->_sendCommand( 'HELO ' . $ehloFqdn, 250 );
}
/* Is TLS being used? */
@@ -163,7 +166,7 @@ class Smtp extends Email
}
/* Exchange server (at least) wants EHLO resending for STARTTLS */
$this->_sendCommand($helo . ' ' . $this->smtpHost, 250);
$this->_sendCommand( $helo . ' ' . $ehloFqdn, 250 );
}
/* Authenticate */
@@ -193,19 +196,24 @@ class Smtp extends Email
$fromEmail = $fromEmail ?: Settings::i()->email_out;
/* SMTP requires you to do CC/BCC by sending a RCPT TO command for each recipient. We'll hide BCC by not actually setting that header */
$recipieintsForSmtp = explode( ',', static::_parseRecipients( $to, TRUE ) );
$recipientsForSmtp = explode( ',', static::_parseRecipients( $to, TRUE ) );
if ( $cc )
{
$recipieintsForSmtp = array_merge( $recipieintsForSmtp, explode( ',', static::_parseRecipients( $cc, TRUE ) ) );
$recipientsForSmtp = array_merge( $recipientsForSmtp, explode( ',', static::_parseRecipients( $cc, TRUE ) ) );
}
if ( $bcc )
{
$recipieintsForSmtp = array_merge( $recipieintsForSmtp, explode( ',', static::_parseRecipients( $bcc, TRUE ) ) );
$recipientsForSmtp = array_merge( $recipientsForSmtp, explode( ',', static::_parseRecipients( $bcc, TRUE ) ) );
}
$recipieintsForSmtp = array_unique( array_map( 'trim', $recipieintsForSmtp ) );
$recipientsForSmtp = array_unique( array_map( 'trim', $recipientsForSmtp ) );
if( empty( $additonalHeaders['Message-ID'] ) )
{
$additionalHeaders['Message-ID'] = $this->generateMessageId();
}
/* Send */
$this->_sendCompiled( $fromEmail, $recipieintsForSmtp, $this->compileFullEmail( $to, $cc, array(), $fromEmail, $fromName, $additionalHeaders ) );
$this->_sendCompiled( $fromEmail, $recipientsForSmtp, $this->compileFullEmail( $to, $cc, array(), $fromEmail, $fromName, $additionalHeaders ) );
}
/**
@@ -292,6 +300,18 @@ class Smtp extends Email
/* Return */
return $response;
}
/**
* Generate random message-id
*
* @return string
*/
public function generateMessageId(): string
{
$randomString = base_convert( microtime() . '.' . Login::generateRandomString(), 10, 36 );
return '<' . $randomString . '@' . Url::internal('')->data[ Url::COMPONENT_HOST ] . '>';
}
/**
* Return the SMTP log
+5
View File
@@ -526,6 +526,11 @@ class GeoLocation
* @brief VAT (used in Commerce)
*/
public ?string $vat = NULL;
/**
* @brief Optional business name used for Commerce
*/
public ?string $business = null;
/**
* Get Requester Location
+1
View File
@@ -125,6 +125,7 @@ class Editor extends FormAbstract
protected static array $editorRestrictions = [
"heading",
"heading_1",
'tables',
"box",
"box_color",
"float",
+6
View File
@@ -166,6 +166,12 @@ class Menu
*/
public function addSeparator(): self
{
/* Skip if there are no elements yet */
if( !count( $this->elements ) )
{
return $this;
}
$this->elements[] = new Separator;
return $this;
}
+5 -5
View File
@@ -83,7 +83,7 @@ class Response
}
$firstLineBreak = mb_strpos( $content, "\n" );
if ( preg_match( "/^HTTP\/(.+?) (\d+?) (.+?)$/", mb_substr( $content, 0, $firstLineBreak ), $matches ) )
if ( preg_match( "/^HTTP\/(.+?) (\d+?) (.+?)$/", \substr( $content, 0, $firstLineBreak ), $matches ) )
{
$this->httpResponseVersion = $matches[1];
$this->httpResponseCode = $matches[2];
@@ -93,12 +93,12 @@ class Response
/* If we have received a 1xx response code, that means it's not the final response */
if ( mb_substr( $this->httpResponseCode, 0, 1 ) == 1 OR ( $this->httpResponseCode == 200 AND trim( $this->httpResponseText ) == 'Connection established' ) )
{
$content = trim( mb_substr( $content, $firstLineBreak ) );
$content = trim( \substr( $content, $firstLineBreak ) );
}
/* Or a 3xx response with another response */
elseif ( mb_substr( $this->httpResponseCode, 0, 1 ) == 3 AND preg_match( "/^HTTP\//m", mb_substr( $content, $firstLineBreak ) ) )
elseif ( mb_substr( $this->httpResponseCode, 0, 1 ) == 3 AND preg_match( "/^HTTP\//m", \substr( $content, $firstLineBreak ) ) )
{
$_content = trim( mb_substr( $content, $firstLineBreak ) );
$_content = trim( \substr( $content, $firstLineBreak ) );
if ( $_content )
{
$content = $_content;
@@ -112,7 +112,7 @@ class Response
while ( TRUE );
/* Split into headers and content */
$split = preg_split( "/\r\n\r\n/", mb_substr( $content, $firstLineBreak ), 2 );
$split = preg_split( "/\r\n\r\n/", \substr( $content, $firstLineBreak ), 2 );
if ( isset( $split[1] ) )
{
$this->content = $split[1];
+2 -2
View File
@@ -86,7 +86,7 @@ class Twitter extends OAuth1
*/
public function buttonColor(): string
{
return '#1d9bf0';
return '#000000';
}
/**
@@ -127,7 +127,7 @@ class Twitter extends OAuth1
*/
public function logoForDeviceInformation(): Url|string|null
{
return Theme::i()->resource( 'logos/login/Twitter.png', 'core', 'interface' );
return Theme::i()->resource( 'logos/login/X.png', 'core', 'interface' );
}
/**
+12 -2
View File
@@ -25,6 +25,7 @@ use IPS\Login\Success;
use IPS\Member\Device;
use IPS\Session\Front;
use OutOfRangeException;
use UnderflowException;
use function count;
use function defined;
use function function_exists;
@@ -176,7 +177,7 @@ class Login
or ( $this->type === static::LOGIN_UCP and $method->front )
or ( $this->type === static::LOGIN_ACP and $method->acp )
or ( $this->type === static::LOGIN_REGISTRATION_FORM and $method->register )
or ( $this->type === static::LOGIN_REAUTHENTICATE and $method->canProcess( $this->reauthenticateAs ) and !( $method instanceof Handler ) )
or ( $this->type === static::LOGIN_REAUTHENTICATE and $method->canProcess( $this->reauthenticateAs ) and $method instanceof Handler )
) {
$methods[ $k ] = $method;
}
@@ -536,7 +537,16 @@ class Login
$url = Url::internal( "app=core&module=system&controller=lostpass", "front", "lostpassword" );
return Member::loggedIn()->language()->addToStack( 'member_email_exists', FALSE, array( 'sprintf' => array( (string) $url ) ) );
}
/* Check if someone requested to change to this email */
try
{
$test = Db::i()->select( '*', 'core_validating', [ 'email_chg=? and new_email=?', 1, $email ] )->first();
$url = Url::internal( "app=core&module=system&controller=lostpass", "front", "lostpassword" );
return Member::loggedIn()->language()->addToStack( 'member_email_exists', FALSE, array( 'sprintf' => array( (string) $url ) ) );
}
catch( UnderflowException ){}
/* Check each handler */
foreach( static::methods() as $k => $handler )
{
+19 -12
View File
@@ -169,6 +169,11 @@ class Club extends ActiveRecord implements Embeddable
* @brief Status: leader
*/
const STATUS_LEADER = 'leader';
/**
* @brief Not actually a status, but used for member history
*/
const STATUS_LEFT = 'left';
/**
* @brief [ActiveRecord] Multiton Store
@@ -1308,18 +1313,6 @@ class Club extends ActiveRecord implements Embeddable
$save['joined'] = time();
}
/* Log to Member History */
if ( in_array( $status, array( Club::STATUS_MEMBER, Club::STATUS_BANNED ) ) )
{
$memberStatus = $this->memberStatus( $member, 2 );
/* Joining by invite */
if ( $memberStatus['status'] == Club::STATUS_INVITED )
{
$addedBy = Member::load( $memberStatus['invited_by'] );
}
$member->logHistory( 'core', 'club_membership', array('club_id' => $this->id, 'type' => $status ), $addedBy );
}
Db::i()->update( 'core_clubs_memberships', $save, array( 'club_id=? AND member_id=?', $this->id, $member->member_id ) );
$member->rebuildPermissionArray();
@@ -1335,6 +1328,17 @@ class Club extends ActiveRecord implements Embeddable
}
}
/* Log to Member History */
$memberStatus = $this->memberStatus( $member, 2 );
if( in_array( $memberStatus['status'], [ Club::STATUS_INVITED, Club::STATUS_INVITED_BYPASSING_PAYMENT, Club::STATUS_BANNED, Club::STATUS_MEMBER, Club::STATUS_DECLINED, Club::STATUS_REQUESTED ] ) )
{
if ( $memberStatus['status'] == Club::STATUS_INVITED )
{
$addedBy = Member::load( $memberStatus['invited_by'] );
}
$member->logHistory( 'core', 'club_membership', array('club_id' => $this->id, 'type' => $status ), $addedBy );
}
$params = [
'club' => $this,
'member' => $member,
@@ -1384,6 +1388,9 @@ class Club extends ActiveRecord implements Embeddable
*/
public function removeMember( Member $member ) : void
{
/* Get the member's current status */
$memberStatus = $this->memberStatus( $member );
Db::i()->delete( 'core_clubs_memberships', array( 'club_id=? AND member_id=?', $this->id, $member->member_id ) );
Webhook::fire( 'club_member_removed', ['club' => $this, 'member' => $member] );
+106 -1
View File
@@ -34,6 +34,7 @@ use IPS\core\ProfileFields\Api\Field;
use IPS\core\ProfileFields\Api\FieldGroup;
use IPS\core\Reports\Report;
use IPS\Data\Store;
use IPS\Db;
use IPS\Db\Select;
use IPS\Events\Event;
use IPS\Helpers\CoverPhoto;
@@ -56,6 +57,7 @@ use IPS\Member\UserMenu;
use IPS\Patterns\ActiveRecord;
use IPS\Patterns\ActiveRecordIterator;
use IPS\Platform\Bridge;
use IPS\Settings;
use IPS\Text\Encrypt;
use IPS\Xml\SimpleXML;
use OutOfRangeException;
@@ -102,6 +104,11 @@ class Member extends ActiveRecord
*/
const SPAM_CHECK_DORMANT_THRESHOLD = 'P6M';
/**
* @brief Period for login after inactivity notification
*/
const LOGIN_INACTIVITY_NOTIFICATION = 'P6M';
/**
* @brief Application
*/
@@ -2987,6 +2994,42 @@ class Member extends ActiveRecord
return NULL;
}
/**
* Change the member's email address
*
* @param string $newEmail
* @return void
*/
public function changeEmail( string $newEmail ) : void
{
/* Disable syncing */
$profileSync = $this->profilesync;
if ( isset( $profileSync['email'] ) )
{
unset( $profileSync['email'] );
$this->profilesync = $profileSync;
$this->save();
}
/* Change the email */
$oldEmail = $this->email;
$this->email = $newEmail;
$this->save();
foreach ( Login::methods() as $method )
{
try
{
$method->changeEmail( $this, $oldEmail, $newEmail );
}
catch( BadMethodCallException $e ){}
}
$this->logHistory( 'core', 'email_change', array( 'old' => $oldEmail, 'new' => $newEmail, 'by' => 'manual' ) );
Db::i()->delete( 'core_validating', [ 'member_id=? and email_chg=?', $this->member_id, 1 ] );
Event::fire( 'onEmailChange', $this, array( $newEmail, $oldEmail ) );
}
/**
* @brief Cached notifications configuration
* @note This property is public so it can be populated en-masse in areas that may benefit from doing so
@@ -4965,7 +5008,7 @@ class Member extends ActiveRecord
/* Send email for validation */
if ( $validationType != 'admin' )
{
Email::buildFromTemplate( 'core', 'registration_validate', array( $this, $vid, $plainSecurityKey ), Email::TYPE_TRANSACTIONAL )->send( $this );
Email::buildFromTemplate( 'core', 'registration_validate', array( $this, $vid, $plainSecurityKey, '' ), Email::TYPE_TRANSACTIONAL )->send( $this );
}
/* Update core_post_before_registering */
@@ -6570,6 +6613,8 @@ class Member extends ActiveRecord
*/
public function recordLogin() : void
{
$this->sendLoginAfterInactivityNotification();
try
{
Db::i()->insert( 'core_members_logins', [ 'member_id' => $this->member_id, 'member_timestamp' => time(), 'member_date' => date( 'Y-m-d', DateTime::ts( time() )->getTimestamp() ) ] );
@@ -6577,6 +6622,66 @@ class Member extends ActiveRecord
catch( Exception $e ) { }
}
/**
* If required, send an email notification when signing in after a period of inactivity.
*
* @return bool
*/
public function sendLoginAfterInactivityNotification(): bool
{
if( !Settings::i()->login_after_inactivity_notification )
{
return false;
}
try
{
$lastLogin = Db::i()->select( 'member_timestamp', 'core_members_logins', [ 'member_id=?', $this->member_id ], 'member_timestamp DESC', 1 )->first();
}
catch( \UnderflowException $e )
{
/* This is the first login, or they haven't logged in since upgrading from < 106100 */
try
{
$lastLogin = Db::i()->select( 'MIN(upgrade_date)', 'core_upgrade_history', [ 'upgrade_app=? AND upgrade_version_id>=?', 'core', 106100 ] )->first();
}
catch( \UnderflowException $e )
{
/* This could be a new install, or IN_DEV where upgrade history isn't available */
return false;
}
}
$lastLoginObj = DateTime::ts( $lastLogin );
/* Likely to be the first login */
if( $this->joined > $lastLoginObj )
{
return false;
}
/* If this login is within the period, don't send an email */
if( $lastLoginObj > DateTime::create()->sub( new \DateInterval( static::LOGIN_INACTIVITY_NOTIFICATION ) ) )
{
return false;
}
/* Device data */
$device = Device::loadOrCreate( $this, false );
try
{
$location = GeoLocation::getRequesterLocation();
}
catch ( \Exception $e )
{
$location = NULL;
}
Email::buildFromTemplate( 'core', 'loginAfterInactivity', array( $this, $device, $location ), Email::TYPE_TRANSACTIONAL )->send( $this );
return true;
}
/**
* Webhook filters
*
+9 -3
View File
@@ -2241,7 +2241,8 @@ abstract class Model extends ActiveRecord
$groupsToCheck = $member->groups;
/* Work out which to use taking Post Before Registering into consideration and return */
$considerPostBeforeRegistering = ( $considerPostBeforeRegistering and Settings::i()->post_before_registering and Login::registrationType() != 'disabled' );
$considerPostBeforeRegistering = ( $considerPostBeforeRegistering and Settings::i()->post_before_registering
and Login::registrationType() != 'disabled' and Settings::i()->bot_antispam_type !== 'none' );
if ( !$member->member_id and $considerPostBeforeRegistering )
{
$groupsToCheck[] = Settings::i()->member_group;
@@ -2651,7 +2652,8 @@ abstract class Model extends ActiveRecord
$relevantPermissionArray = array_filter( explode( ',', $relevantPermissionString ) );
/* Work out which to use taking Post Before Registering into consideration and return */
$considerPostBeforeRegistering = ( $considerPostBeforeRegistering and Settings::i()->post_before_registering and Login::registrationType() != 'disabled' );
$considerPostBeforeRegistering = ( $considerPostBeforeRegistering and Settings::i()->post_before_registering
and Login::registrationType() != 'disabled' and Settings::i()->bot_antispam_type !== 'none' );
if( $member instanceof Group )
{
if ( $considerPostBeforeRegistering and $member->g_id == Settings::i()->guest_group )
@@ -3669,7 +3671,11 @@ abstract class Model extends ActiveRecord
*/
public function saveForm( array $values ): mixed
{
$values = UiExtension::i()->run( $this, 'processForm', [ $values ] );
/* Loop through each extension and update the values */
foreach( UiExtension::i()->getObjectExtensions( $this ) as $extension )
{
$values = $extension->processForm( $this, $values );
}
foreach ( $values as $k => $v )
{
+14 -12
View File
@@ -1852,28 +1852,30 @@ class Output
foreach( $this->breadcrumb as $breadcrumb )
{
$crumb = [
'@type' => "ListItem",
'position' => $position,
'item' => [
'name' => $breadcrumb[1],
]
];
if( $breadcrumb[0] )
{
$elements[] = array(
'@type' => "ListItem",
'position' => $position,
'item' => array(
'@id' => (string) $breadcrumb[0],
'name' => $breadcrumb[1],
)
);
$position++;
$crumb['item']['@id'] = (string) $breadcrumb[0];
}
$elements[] = $crumb;
$position++;
}
if( count( $elements ) )
{
$jsonLd['breadcrumbs'] = array(
$jsonLd['breadcrumbs'] = [
'@context' => "https://schema.org",
'@type' => "BreadcrumbList",
'itemListElement' => $elements,
);
];
}
}
+66 -51
View File
@@ -92,66 +92,20 @@ class UiExtension extends Singleton
*/
public function run( Content|Model|string $object, string $method, ?array $payload=array() ) : array
{
$className = is_string( $object ) ? $object : get_class( $object );
$baseClass = null;
switch( $className )
{
case '':
default:
return [];
case is_subclass_of( $className, Model::class ):
/* If this node class cannot be extended, just return and do nothing */
if( !$className::$canBeExtended )
{
return [];
}
$type = 'Node';
$baseClass = Model::class;
break;
case is_subclass_of( $className, Content\Item::class ):
$type = 'Item';
$baseClass = Content\Item::class;
break;
case is_subclass_of( $className, Content\Review::class ):
$type = 'Review';
$baseClass = Content\Review::class;
break;
case is_subclass_of( $className, Content\Comment::class ):
$type = 'Comment';
$baseClass = Content\Comment::class;
break;
}
$return = [];
/* If we are working with an object (Item/Comment/Node), put it first in the parameter list */
/* If we are working with an object (Item/Comment/Node), put it first in the parameter list */
$params = is_array( $payload ) ? array_values( $payload ) : array();
array_unshift( $params, is_string( $object ) ? null : $object );
foreach( $this->extensions( $type ) as $extension )
foreach( $this->getObjectExtensions( $object ) as $extension )
{
/* Use subclass to account for classes in Pages */
if( isset( $extension::$class ) AND $className != $extension::$class AND !is_subclass_of( $className, $extension::$class ) )
if ( $response = $extension->$method( ...$params ) )
{
continue;
}
/* Block extension of base classes */
if( $baseClass !== null AND $extension::$class == $baseClass )
{
continue;
}
if( $response = $extension->$method( ...$params ) )
{
if( is_array( $response ) )
if ( is_array( $response ) )
{
$return = array_merge( $return, $response );
}
else
} else
{
$return[] = $response;
}
@@ -160,4 +114,65 @@ class UiExtension extends Singleton
return $return;
}
/**
* Return all available extensions for this object
*
* @param Content|Model|string $object
* @return array
*/
public function getObjectExtensions( Content|Model|string $object ) : array
{
$className = is_string( $object ) ? $object : get_class( $object );
$baseClass = null;
switch ( $className )
{
case '':
default:
return [];
case is_subclass_of( $className, Model::class ):
/* If this node class cannot be extended, just return and do nothing */
if ( !$className::$canBeExtended )
{
return [];
}
$type = 'Node';
$baseClass = Model::class;
break;
case is_subclass_of( $className, Content\Item::class ):
$type = 'Item';
$baseClass = Content\Item::class;
break;
case is_subclass_of( $className, Content\Review::class ):
$type = 'Review';
$baseClass = Content\Review::class;
break;
case is_subclass_of( $className, Content\Comment::class ):
$type = 'Comment';
$baseClass = Content\Comment::class;
break;
}
$return = [];
foreach ( $this->extensions( $type ) as $extension )
{
/* Use subclass to account for classes in Pages */
if ( isset( $extension::$class ) and $className != $extension::$class and !is_subclass_of( $className, $extension::$class ) )
{
continue;
}
/* Block extension of base classes */
if ( $baseClass !== null and $extension::$class == $baseClass )
{
continue;
}
$return[] = $extension;
}
return $return;
}
}
+1 -1
View File
@@ -278,7 +278,7 @@ class Redis
{
if ( method_exists( 'Redis', $method ) )
{
$type = ( stristr( $method, 'get' ) or stristr( $method, 'RevRange' ) ) ? 'read' : 'write';
$type = ( stristr( $method, 'get' ) or stristr( $method, 'RevRange' ) or $method === 'lRange' ) ? 'read' : 'write';
$return = $this->connection( $type )->$method( ...$args );
if ( $type === 'write' and count( $args ) )
+25 -19
View File
@@ -325,6 +325,11 @@ class Redis extends Store
* Redis key
*/
protected int $fetchAttempt = 0;
/**
* @var array|null cache online user data after fetching it
*/
protected ?array $onlineUsers = null;
/**
* Fetch all online users (but not spiders)
@@ -338,19 +343,20 @@ class Redis extends Store
*/
public function getOnlineUsers( int $flags=0, string $sort='desc', array $limit=NULL, int $memberGroup=NULL, bool $showAnonymous=FALSE ): array|int
{
try
if( $this->onlineUsers === NULL )
{
$results = RedisClass::i()->lRange( static::_key( 'session_onlinelist' ), 0, -1 );
}
catch ( RedisException $e )
{
/* Something went wrong, so reset the key to force a new file */
static::resetKey();
$results = NULL;
try
{
$this->onlineUsers = RedisClass::i()->lRange( static::_key( 'session_onlinelist' ), 0, -1 );
}
catch ( RedisException $e )
{
/* Something went wrong, so reset the key to force a new file */
static::resetKey();
}
}
if ( ! $results )
if ( !$this->onlineUsers )
{
/* Ensure file is deleted */
try
@@ -383,10 +389,10 @@ class Redis extends Store
try
{
/* Force expiration in 30 seconds to prevent stale caches hanging around */
RedisClass::i()->expire( static::_key( 'session_onlinelist' ), 30 );
$results = RedisClass::i()->lRange( static::_key( 'session_onlinelist' ), 0, -1 );
/* Force expiration in 60 seconds to prevent stale caches hanging around */
RedisClass::i()->expire( static::_key( 'session_onlinelist' ), 60 );
$this->onlineUsers = RedisClass::i()->lRange( static::_key( 'session_onlinelist' ), 0, -1 );
}
catch ( RedisException $e )
{
@@ -402,7 +408,7 @@ class Redis extends Store
}
else
{
$results = array();
$this->onlineUsers = [];
}
}
}
@@ -414,7 +420,7 @@ class Redis extends Store
$return = array();
$i = 0;
while( $i < count( $results ) )
while( $i < count( $this->onlineUsers ) )
{
$fields = array();
$data = NULL;
@@ -424,7 +430,7 @@ class Redis extends Store
{
try
{
$data = RedisClass::i()->decode( $results[ $i++ ] );
$data = RedisClass::i()->decode( $this->onlineUsers[ $i++ ] );
}
catch( RedisException $e )
{
@@ -434,11 +440,11 @@ class Redis extends Store
/* login_type must be cast as an integer or else anonymous state can be lost when adjustSessions() runs */
elseif( $field === 'login_type' )
{
$fields[ $field ] = (int) $results[ $i++ ];
$fields[ $field ] = (int) $this->onlineUsers[ $i++ ];
}
else
{
$fields[ $field ] = $results[ $i++ ];
$fields[ $field ] = $this->onlineUsers[ $i++ ];
}
}
+90 -1
View File
@@ -1051,6 +1051,9 @@ class Parser
$return[] = 'ipsEmbedded_og__' . $k;
}
/* Tables */
$return[] = "ipsRichText__table-wrapper";
/* Custom */
foreach( Application::enabledApplications() as $app )
{
@@ -1280,6 +1283,90 @@ class Parser
/* Note what $parent is */
$originalParent = $parent;
/* Acronym? */
if ( array_key_exists( $section, $this->caseSensitiveAcronyms ) and !in_array( $this->caseSensitiveAcronyms[ $section ], $this->openAbbrTags ) )
{
switch( $this->caseSensitiveAcronyms[ $section ]['a_type'] )
{
case 'acronym':
$parent = $parent->appendChild( new DOMElement( 'abbr' ) );
$parent->setAttribute( 'title', $this->caseSensitiveAcronyms[ $section ]['a_long'] );
break;
case 'link':
$replace = ( $parent->tagName != 'a' );
$parentNode = $parent;
while( ( $parentNode = $parentNode->parentNode ) !== NULL )
{
if( $parentNode instanceof DOMElement AND $parentNode->tagName == 'a' )
{
$replace = FALSE;
break;
}
}
if( $replace )
{
$parent = $parent->appendChild( new DOMElement( 'a' ) );
$parent->setAttribute( 'href', $this->caseSensitiveAcronyms[ $section ]['a_long'] );
try
{
$rels = $this->_getRelAttributes( Url::createFromString( $this->caseSensitiveAcronyms[ $section ]['a_long'] ) );
}
catch(Url\Exception $e )
{
$rels = array();
}
/* Add rels */
$parent->setAttribute( 'rel', implode( ' ', $rels ) );
}
break;
}
}
elseif ( array_key_exists( mb_strtolower( $section ), $this->caseInsensitiveAcronyms ) and !in_array( $this->caseInsensitiveAcronyms[ mb_strtolower( $section ) ], $this->openAbbrTags ) )
{
switch( $this->caseInsensitiveAcronyms[ mb_strtolower( $section ) ]['a_type'] )
{
case 'acronym':
$parent = $parent->appendChild( new DOMElement( 'abbr' ) );
$parent->setAttribute( 'title', $this->caseInsensitiveAcronyms[ mb_strtolower( $section ) ]['a_long'] );
break;
case 'link':
$replace = ( $parent->tagName != 'a' );
$parentNode = $parent;
while( ( $parentNode = $parentNode->parentNode ) !== NULL )
{
if( $parentNode instanceof DOMElement AND $parentNode->tagName == 'a' )
{
$replace = FALSE;
break;
}
}
if( $replace )
{
$parent = $parent->appendChild( new DOMElement( 'a' ) );
$parent->setAttribute( 'href', $this->caseInsensitiveAcronyms[ mb_strtolower( $section ) ]['a_long'] );
try
{
$rels = $this->_getRelAttributes( Url::createFromString( $this->caseInsensitiveAcronyms[ mb_strtolower( $section ) ]['a_long'] ) );
}
catch(Url\Exception $e )
{
$rels = array();
}
/* Add rels */
$parent->setAttribute( 'rel', implode( ' ', $rels ) );
}
break;
}
}
/* Emoji? */
if ( ( !$originalParent->getAttribute('class') or !in_array( 'ipsEmoji', explode( ' ', $originalParent->getAttribute('class') ) ) ) and preg_match( '/' . static::getEmojiRegex() . '/u', $section ) )
{
@@ -1641,7 +1728,8 @@ class Parser
}
}
$element->setAttribute( 'sandbox', 'allow-scripts' );
$element->setAttribute( 'sandbox', 'allow-scripts allow-same-origin' );
$element->setAttribute( 'allowfullscreen', '' );
// the width and height need to be set in the style attribute
$style = "";
foreach ( [ 'width', 'height' ] as $attr )
@@ -2322,6 +2410,7 @@ class Parser
'tiktok.com' => array( 'https://www.tiktok.com/oembed', static::EMBED_VIDEO ),
'm.tiktok.com' => array( 'https://www.tiktok.com/oembed', static::EMBED_VIDEO ),
'vm.tiktok.com' => array( 'https://www.tiktok.com/oembed', static::EMBED_VIDEO ),
'bsky.app' => [ 'https://embed.bsky.app/oembed', static::EMBED_TWEET ]
);
/* Can we support Facebook and Instagram oembeds? */
+1 -1
View File
@@ -3889,7 +3889,7 @@ EOF;
{
$content = $content . "\n<p class='ipsCopyright'>
<span class='ipsCopyright__user'>{lang=\"copyright_line_value\"}</span>
{{if !\$licenseData = \IPS\IPS::licenseKey() or !isset(\$licenseData['products']['copyright']) or !\$licenseData['products']['copyright']}}<a rel='nofollow' title='Invision Community' href='https://www.invisioncommunity.com/'>Powered by Invision Community</a>{{endif}}
{{if !\$licenseData = \IPS\IPS::licenseKey() or !isset(\$licenseData['products']['copyright']) or !\$licenseData['products']['copyright']}}<a rel='nofollow' title='Invision Community' href='https://www.invisioncommunity.com/'>Powered by <span translate='no'>Invision Community</span></a>{{endif}}
</p>";
}