Version 4.7.13

This commit is contained in:
Neo committed 2025-12-19 16:14:56 -08:00
1 parent fd22d99e69
commit 7124a02564
954 files changed
+29277 -59308

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
<?php
// DO NOT EDIT THIS FILE - IT IS GENERATED BY constant_generator.php
namespace donatj\UserAgent;
interface Browsers {
const ADSBOT_GOOGLE = 'AdsBot-Google';
const ANDROID_BROWSER = 'Android Browser';
const APPLEBOT = 'Applebot';
const BAIDUSPIDER = 'Baiduspider';
const BINGBOT = 'bingbot';
const BLACKBERRY_BROWSER = 'BlackBerry Browser';
const BROWSER = 'Browser';
const BUNJALLOO = 'Bunjalloo';
const CAMINO = 'Camino';
const CHROME = 'Chrome';
const CURL = 'curl';
const EDGE = 'Edge';
const FACEBOOKEXTERNALHIT = 'facebookexternalhit';
const FEEDVALIDATOR = 'FeedValidator';
const FIREFOX = 'Firefox';
const GOOGLEBOT = 'Googlebot';
const GOOGLEBOT_IMAGE = 'Googlebot-Image';
const GOOGLEBOT_VIDEO = 'Googlebot-Video';
const HEADLESSCHROME = 'HeadlessChrome';
const IEMOBILE = 'IEMobile';
const IMESSAGEBOT = 'iMessageBot';
const KINDLE = 'Kindle';
const LYNX = 'Lynx';
const MIDORI = 'Midori';
const MIUIBROWSER = 'MiuiBrowser';
const MSIE = 'MSIE';
const MSNBOT_MEDIA = 'msnbot-media';
const NETFRONT = 'NetFront';
const NINTENDOBROWSER = 'NintendoBrowser';
const OCULUSBROWSER = 'OculusBrowser';
const OPERA = 'Opera';
const PUFFIN = 'Puffin';
const SAFARI = 'Safari';
const SAILFISHBROWSER = 'SailfishBrowser';
const SAMSUNGBROWSER = 'SamsungBrowser';
const SILK = 'Silk';
const TELEGRAMBOT = 'TelegramBot';
const TIZENBROWSER = 'TizenBrowser';
const TWITTERBOT = 'Twitterbot';
const UC_BROWSER = 'UC Browser';
const VALVE_STEAM_TENFOOT = 'Valve Steam Tenfoot';
const VIVALDI = 'Vivaldi';
const WGET = 'Wget';
const WORDPRESS = 'WordPress';
const YANDEX = 'Yandex';
const YANDEXBOT = 'YandexBot';
}
+42
View File
@@ -0,0 +1,42 @@
<?php
// DO NOT EDIT THIS FILE - IT IS GENERATED BY constant_generator.php
namespace donatj\UserAgent;
interface Platforms {
const MACINTOSH = 'Macintosh';
const CHROME_OS = 'Chrome OS';
const LINUX = 'Linux';
const WINDOWS = 'Windows';
const ANDROID = 'Android';
const BLACKBERRY = 'BlackBerry';
const FREEBSD = 'FreeBSD';
const IPAD = 'iPad';
const IPHONE = 'iPhone';
const IPOD = 'iPod';
const KINDLE = 'Kindle';
const KINDLE_FIRE = 'Kindle Fire';
const NETBSD = 'NetBSD';
const NEW_NINTENDO_3DS = 'New Nintendo 3DS';
const NINTENDO_3DS = 'Nintendo 3DS';
const NINTENDO_DS = 'Nintendo DS';
const NINTENDO_SWITCH = 'Nintendo Switch';
const NINTENDO_WII = 'Nintendo Wii';
const NINTENDO_WIIU = 'Nintendo WiiU';
const OPENBSD = 'OpenBSD';
const PLAYBOOK = 'PlayBook';
const PLAYSTATION_3 = 'PlayStation 3';
const PLAYSTATION_4 = 'PlayStation 4';
const PLAYSTATION_5 = 'PlayStation 5';
const PLAYSTATION_VITA = 'PlayStation Vita';
const SAILFISH = 'Sailfish';
const SYMBIAN = 'Symbian';
const TIZEN = 'Tizen';
const WINDOWS_PHONE = 'Windows Phone';
const XBOX = 'Xbox';
const XBOX_ONE = 'Xbox One';
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace donatj\UserAgent;
class UserAgent implements UserAgentInterface {
/**
* @var string|null
*/
private $platform;
/**
* @var string|null
*/
private $browser;
/**
* @var string|null
*/
private $browserVersion;
/**
* UserAgent constructor.
*
* @param string|null $platform
* @param string|null $browser
* @param string|null $browserVersion
*/
public function __construct( $platform, $browser, $browserVersion ) {
$this->platform = $platform;
$this->browser = $browser;
$this->browserVersion = $browserVersion;
}
/**
* @return string|null
* @see \donatj\UserAgent\Platforms for a list of tested platforms
*/
public function platform() {
return $this->platform;
}
/**
* @return string|null
* @see \donatj\UserAgent\Browsers for a list of tested browsers.
*/
public function browser() {
return $this->browser;
}
/**
* The version string. Formatting depends on the browser.
*
* @return string|null
*/
public function browserVersion() {
return $this->browserVersion;
}
}
@@ -0,0 +1,25 @@
<?php
namespace donatj\UserAgent;
interface UserAgentInterface {
/**
* @return string|null
* @see \donatj\UserAgent\Platforms for a list of tested platforms
*/
public function platform();
/**
* @return string|null
* @see \donatj\UserAgent\Browsers for a list of tested browsers.
*/
public function browser();
/**
* The version string. Formatting depends on the browser.
*
* @return string|null
*/
public function browserVersion();
}
@@ -0,0 +1,39 @@
<?php
namespace donatj\UserAgent;
class UserAgentParser {
/**
* Parses a user agent string into its important parts, provide an object
*
* @param string|null $u_agent User agent string to parse or null. Uses $_SERVER['HTTP_USER_AGENT'] on NULL
* @return UserAgent an object with 'browser', 'browserVersion' and 'platform' methods
* @throws \InvalidArgumentException on not having a proper user agent to parse.
* @see \donatj\UserAgent\parse_user_agent()
*
*/
public function parse( $u_agent = null ) {
$parsed = parse_user_agent($u_agent);
return new UserAgent(
$parsed[PLATFORM],
$parsed[BROWSER],
$parsed[BROWSER_VERSION]
);
}
/**
* Parses a user agent string into its important parts
*
* @param string|null $u_agent User agent string to parse or null. Uses $_SERVER['HTTP_USER_AGENT'] on NULL
* @return UserAgent an object with 'browser', 'browserVersion' and 'platform' methods
* @throws \InvalidArgumentException on not having a proper user agent to parse.
* @see \donatj\UserAgent\parse_user_agent()
*
*/
public function __invoke( $u_agent = null ) {
return $this->parse($u_agent);
}
}
+181 -144
View File
@@ -1,181 +1,218 @@
<?php
/**
* Parses a user agent string into its important parts
*
* @param string|null $u_agent User agent string to parse or null. Uses $_SERVER['HTTP_USER_AGENT'] on NULL
* @return string[] an array with browser, version and platform keys
* @throws \InvalidArgumentException on not having a proper user agent to parse.
*
* @author Jesse G. Donat <donatj@gmail.com>
*
* @link https://donatstudios.com/PHP-Parser-HTTP_USER_AGENT
* @link https://github.com/donatj/PhpUserAgent
*
* @license MIT
* @license MIT https://github.com/donatj/PhpUserAgent/blob/master/LICENSE.md
*/
function parse_user_agent( $u_agent = null ) {
if( $u_agent === null && isset($_SERVER['HTTP_USER_AGENT']) ) {
$u_agent = $_SERVER['HTTP_USER_AGENT'];
namespace {
/**
* Parses a user agent string into its important parts
*
* This method is defined for backwards comparability with the old global method.
*
* @param string|null $u_agent User agent string to parse or null. Uses $_SERVER['HTTP_USER_AGENT'] on NULL
* @return string[] an array with 'browser', 'version' and 'platform' keys
* @throws \InvalidArgumentException on not having a proper user agent to parse.
*
* @deprecated This exists for backwards compatibility with 0.x and will likely be removed in 2.x
* @see \donatj\UserAgent\parse_user_agent
*/
function parse_user_agent( $u_agent = null ) {
return \donatj\UserAgent\parse_user_agent($u_agent);
}
}
if( $u_agent === null ) {
throw new \InvalidArgumentException('parse_user_agent requires a user agent');
}
namespace donatj\UserAgent {
$platform = null;
$browser = null;
$version = null;
const PLATFORM = 'platform';
const BROWSER = 'browser';
const BROWSER_VERSION = 'version';
$empty = array( 'platform' => $platform, 'browser' => $browser, 'version' => $version );
/**
* Parses a user agent string into its important parts
*
* @param string|null $u_agent User agent string to parse or null. Uses $_SERVER['HTTP_USER_AGENT'] on NULL
* @return string[] an array with 'browser', 'version' and 'platform' keys
* @throws \InvalidArgumentException on not having a proper user agent to parse.
*/
function parse_user_agent( $u_agent = null ) {
if( $u_agent === null && isset($_SERVER['HTTP_USER_AGENT']) ) {
$u_agent = (string)$_SERVER['HTTP_USER_AGENT'];
}
if( !$u_agent ) {
return $empty;
}
if( $u_agent === null ) {
throw new \InvalidArgumentException('parse_user_agent requires a user agent');
}
if( preg_match('/\((.*?)\)/m', $u_agent, $parent_matches) ) {
preg_match_all('/(?P<platform>BB\d+;|Android|CrOS|Tizen|iPhone|iPad|iPod|Linux|(Open|Net|Free)BSD|Macintosh|Windows(\ Phone)?|Silk|linux-gnu|BlackBerry|PlayBook|X11|(New\ )?Nintendo\ (WiiU?|3?DS|Switch)|Xbox(\ One)?)
(?:\ [^;]*)?
(?:;|$)/imx', $parent_matches[1], $result);
$platform = null;
$browser = null;
$version = null;
$priority = array( 'Xbox One', 'Xbox', 'Windows Phone', 'Tizen', 'Android', 'FreeBSD', 'NetBSD', 'OpenBSD', 'CrOS', 'X11' );
$return = [ PLATFORM => &$platform, BROWSER => &$browser, BROWSER_VERSION => &$version ];
$result['platform'] = array_unique($result['platform']);
if( count($result['platform']) > 1 ) {
if( $keys = array_intersect($priority, $result['platform']) ) {
$platform = reset($keys);
} else {
$platform = $result['platform'][0];
if( !$u_agent ) {
return $return;
}
if( preg_match('/\((.*?)\)/m', $u_agent, $parent_matches) ) {
preg_match_all(<<<'REGEX'
/(?P<platform>BB\d+;|Android|Adr|Symbian|Sailfish|CrOS|Tizen|iPhone|iPad|iPod|Linux|(?:Open|Net|Free)BSD|Macintosh|
Windows(?:\ Phone)?|Silk|linux-gnu|BlackBerry|PlayBook|X11|(?:New\ )?Nintendo\ (?:WiiU?|3?DS|Switch)|Xbox(?:\ One)?)
(?:\ [^;]*)?
(?:;|$)/imx
REGEX
, $parent_matches[1], $result);
$priority = [ 'Xbox One', 'Xbox', 'Windows Phone', 'Tizen', 'Android', 'FreeBSD', 'NetBSD', 'OpenBSD', 'CrOS', 'X11', 'Sailfish' ];
$result[PLATFORM] = array_unique($result[PLATFORM]);
if( count($result[PLATFORM]) > 1 ) {
if( $keys = array_intersect($priority, $result[PLATFORM]) ) {
$platform = reset($keys);
} else {
$platform = $result[PLATFORM][0];
}
} elseif( isset($result[PLATFORM][0]) ) {
$platform = $result[PLATFORM][0];
}
} elseif( isset($result['platform'][0]) ) {
$platform = $result['platform'][0];
}
}
if( $platform == 'linux-gnu' || $platform == 'X11' ) {
$platform = 'Linux';
} elseif( $platform == 'CrOS' ) {
$platform = 'Chrome OS';
}
preg_match_all('%(?P<browser>Camino|Kindle(\ Fire)?|Firefox|Iceweasel|IceCat|Safari|MSIE|Trident|AppleWebKit|
TizenBrowser|(?:Headless)?Chrome|YaBrowser|Vivaldi|IEMobile|Opera|OPR|Silk|Midori|Edge|Edg|CriOS|UCBrowser|Puffin|OculusBrowser|SamsungBrowser|
Baiduspider|Googlebot|YandexBot|bingbot|Lynx|Version|Wget|curl|
Valve\ Steam\ Tenfoot|
NintendoBrowser|PLAYSTATION\ (\d|Vita)+)
(?:\)?;?)
(?:(?:[:/ ])(?P<version>[0-9A-Z.]+)|/(?:[A-Z]*))%ix',
$u_agent, $result);
// If nothing matched, return null (to avoid undefined index errors)
if( !isset($result['browser'][0]) || !isset($result['version'][0]) ) {
if( preg_match('%^(?!Mozilla)(?P<browser>[A-Z0-9\-]+)(/(?P<version>[0-9A-Z.]+))?%ix', $u_agent, $result) ) {
return array( 'platform' => $platform ?: null, 'browser' => $result['browser'], 'version' => isset($result['version']) ? $result['version'] ?: null : null );
}
return $empty;
}
if( $platform == 'linux-gnu' || $platform == 'X11' ) {
$platform = 'Linux';
} elseif( $platform == 'CrOS' ) {
$platform = 'Chrome OS';
} elseif( $platform == 'Adr' ) {
$platform = 'Android';
} elseif( $platform === null ) {
if(preg_match_all('%(?P<platform>Android)[:/ ]%ix', $u_agent, $result)) {
$platform = $result[PLATFORM][0];
}
}
if( preg_match('/rv:(?P<version>[0-9A-Z.]+)/i', $u_agent, $rv_result) ) {
$rv_result = $rv_result['version'];
}
preg_match_all(<<<'REGEX'
%(?P<browser>Camino|Kindle(\ Fire)?|Firefox|Iceweasel|IceCat|Safari|MSIE|Trident|AppleWebKit|
TizenBrowser|(?:Headless)?Chrome|YaBrowser|Vivaldi|IEMobile|Opera|OPR|Silk|Midori|(?-i:Edge)|EdgA?|CriOS|UCBrowser|Puffin|
OculusBrowser|SamsungBrowser|SailfishBrowser|XiaoMi/MiuiBrowser|
Baiduspider|Applebot|Facebot|Googlebot|YandexBot|bingbot|Lynx|Version|Wget|curl|
Valve\ Steam\ Tenfoot|
NintendoBrowser|PLAYSTATION\ (?:\d|Vita)+)
\)?;?
(?:[:/ ](?P<version>[0-9A-Z.]+)|/[A-Z]*)%ix
REGEX
, $u_agent, $result);
$browser = $result['browser'][0];
$version = $result['version'][0];
// If nothing matched, return null (to avoid undefined index errors)
if( !isset($result[BROWSER][0], $result[BROWSER_VERSION][0]) ) {
if( preg_match('%^(?!Mozilla)(?P<browser>[A-Z0-9\-]+)(/(?P<version>[0-9A-Z.]+))?%ix', $u_agent, $result) ) {
return [ PLATFORM => $platform ?: null, BROWSER => $result[BROWSER], BROWSER_VERSION => empty($result[BROWSER_VERSION]) ? null : $result[BROWSER_VERSION] ];
}
$lowerBrowser = array_map('strtolower', $result['browser']);
return $return;
}
$find = function ( $search, &$key, &$value = null ) use ( $lowerBrowser ) {
$search = (array)$search;
if( preg_match('/rv:(?P<version>[0-9A-Z.]+)/i', $u_agent, $rv_result) ) {
$rv_result = $rv_result[BROWSER_VERSION];
}
foreach( $search as $val ) {
$xkey = array_search(strtolower($val), $lowerBrowser);
if( $xkey !== false ) {
$value = $val;
$key = $xkey;
$browser = $result[BROWSER][0];
$version = $result[BROWSER_VERSION][0];
$lowerBrowser = array_map('strtolower', $result[BROWSER]);
$find = function ( $search, &$key = null, &$value = null ) use ( $lowerBrowser ) {
$search = (array)$search;
foreach( $search as $val ) {
$xkey = array_search(strtolower($val), $lowerBrowser);
if( $xkey !== false ) {
$value = $val;
$key = $xkey;
return true;
}
}
return false;
};
$findT = function ( array $search, &$key = null, &$value = null ) use ( $find ) {
$value2 = null;
if( $find(array_keys($search), $key, $value2) ) {
$value = $search[$value2];
return true;
}
}
return false;
};
return false;
};
$key = 0;
$val = '';
if( $browser == 'Iceweasel' || strtolower($browser) == 'icecat' ) {
$browser = 'Firefox';
} elseif( $find('Playstation Vita', $key) ) {
$platform = 'PlayStation Vita';
$browser = 'Browser';
} elseif( $find(array( 'Kindle Fire', 'Silk' ), $key, $val) ) {
$browser = $val == 'Silk' ? 'Silk' : 'Kindle';
$platform = 'Kindle Fire';
if( !($version = $result['version'][$key]) || !is_numeric($version[0]) ) {
$version = $result['version'][array_search('Version', $result['browser'])];
}
} elseif( $find('NintendoBrowser', $key) || $platform == 'Nintendo 3DS' ) {
$browser = 'NintendoBrowser';
$version = $result['version'][$key];
} elseif( $find('Kindle', $key, $platform) ) {
$browser = $result['browser'][$key];
$version = $result['version'][$key];
} elseif( $find('OPR', $key) ) {
$browser = 'Opera';
$version = $result['version'][$key];
} elseif( $find('Opera', $key, $browser) ) {
$find('Version', $key);
$version = $result['version'][$key];
} elseif( $find('Puffin', $key, $browser) ) {
$version = $result['version'][$key];
if( strlen($version) > 3 ) {
$part = substr($version, -2);
if( ctype_upper($part) ) {
$version = substr($version, 0, -2);
$key = 0;
$val = '';
if( $findT([ 'OPR' => 'Opera', 'Facebot' => 'iMessageBot', 'UCBrowser' => 'UC Browser', 'YaBrowser' => 'Yandex', 'Iceweasel' => 'Firefox', 'Icecat' => 'Firefox', 'CriOS' => 'Chrome', 'Edg' => 'Edge', 'EdgA' => 'Edge', 'XiaoMi/MiuiBrowser' => 'MiuiBrowser' ], $key, $browser) ) {
$version = is_numeric(substr($result[BROWSER_VERSION][$key], 0, 1)) ? $result[BROWSER_VERSION][$key] : null;
} elseif( $find('Playstation Vita', $key, $platform) ) {
$platform = 'PlayStation Vita';
$browser = 'Browser';
} elseif( $find([ 'Kindle Fire', 'Silk' ], $key, $val) ) {
$browser = $val == 'Silk' ? 'Silk' : 'Kindle';
$platform = 'Kindle Fire';
if( !($version = $result[BROWSER_VERSION][$key]) || !is_numeric($version[0]) ) {
$version = $result[BROWSER_VERSION][array_search('Version', $result[BROWSER])];
}
} elseif( $find('NintendoBrowser', $key) || $platform == 'Nintendo 3DS' ) {
$browser = 'NintendoBrowser';
$version = $result[BROWSER_VERSION][$key];
} elseif( $find('Kindle', $key, $platform) ) {
$browser = $result[BROWSER][$key];
$version = $result[BROWSER_VERSION][$key];
} elseif( $find('Opera', $key, $browser) ) {
$find('Version', $key);
$version = $result[BROWSER_VERSION][$key];
} elseif( $find('Puffin', $key, $browser) ) {
$version = $result[BROWSER_VERSION][$key];
if( strlen($version) > 3 ) {
$part = substr($version, -2);
if( ctype_upper($part) ) {
$version = substr($version, 0, -2);
$flags = array( 'IP' => 'iPhone', 'IT' => 'iPad', 'AP' => 'Android', 'AT' => 'Android', 'WP' => 'Windows Phone', 'WT' => 'Windows' );
if( isset($flags[$part]) ) {
$platform = $flags[$part];
$flags = [ 'IP' => 'iPhone', 'IT' => 'iPad', 'AP' => 'Android', 'AT' => 'Android', 'WP' => 'Windows Phone', 'WT' => 'Windows' ];
if( isset($flags[$part]) ) {
$platform = $flags[$part];
}
}
}
}
} elseif( $find('YaBrowser', $key, $browser) ) {
$browser = 'Yandex';
$version = $result['version'][$key];
} elseif( $find(array( 'Edge', 'Edg' ), $key, $browser) ) {
$browser = 'Edge';
$version = $result['version'][$key];
} elseif( $find(array( 'IEMobile', 'Midori', 'Vivaldi', 'OculusBrowser', 'SamsungBrowser', 'Valve Steam Tenfoot', 'Chrome', 'HeadlessChrome' ), $key, $browser) ) {
$version = $result['version'][$key];
} elseif( $rv_result && $find('Trident', $key) ) {
$browser = 'MSIE';
$version = $rv_result;
} elseif( $find('UCBrowser', $key) ) {
$browser = 'UC Browser';
$version = $result['version'][$key];
} elseif( $find('CriOS', $key) ) {
$browser = 'Chrome';
$version = $result['version'][$key];
} elseif( $browser == 'AppleWebKit' ) {
if( $platform == 'Android' ) {
$browser = 'Android Browser';
} elseif( strpos($platform, 'BB') === 0 ) {
$browser = 'BlackBerry Browser';
$platform = 'BlackBerry';
} elseif( $platform == 'BlackBerry' || $platform == 'PlayBook' ) {
$browser = 'BlackBerry Browser';
} else {
$find('Safari', $key, $browser) || $find('TizenBrowser', $key, $browser);
} elseif( $find([ 'Applebot', 'IEMobile', 'Edge', 'Midori', 'Vivaldi', 'OculusBrowser', 'SamsungBrowser', 'Valve Steam Tenfoot', 'Chrome', 'HeadlessChrome', 'SailfishBrowser' ], $key, $browser) ) {
$version = $result[BROWSER_VERSION][$key];
} elseif( $rv_result && $find('Trident') ) {
$browser = 'MSIE';
$version = $rv_result;
} elseif( $browser == 'AppleWebKit' ) {
if( $platform == 'Android' ) {
$browser = 'Android Browser';
} elseif( strpos((string)$platform, 'BB') === 0 ) {
$browser = 'BlackBerry Browser';
$platform = 'BlackBerry';
} elseif( $platform == 'BlackBerry' || $platform == 'PlayBook' ) {
$browser = 'BlackBerry Browser';
} else {
$find('Safari', $key, $browser) || $find('TizenBrowser', $key, $browser);
}
$find('Version', $key);
$version = $result[BROWSER_VERSION][$key];
} elseif( $pKey = preg_grep('/playstation \d/i', $result[BROWSER]) ) {
$pKey = reset($pKey);
$platform = 'PlayStation ' . preg_replace('/\D/', '', $pKey);
$browser = 'NetFront';
}
$find('Version', $key);
$version = $result['version'][$key];
} elseif( $pKey = preg_grep('/playstation \d/i', $result['browser']) ) {
$pKey = reset($pKey);
$platform = 'PlayStation ' . preg_replace('/\D/', '', $pKey);
$browser = 'NetFront';
return $return;
}
return array( 'platform' => $platform ?: null, 'browser' => $browser ?: null, 'version' => $version ?: null );
}
+2 -1
View File
@@ -72,7 +72,8 @@ class _PaginatedResponse extends Response
public function __construct( $httpCode, $iterator, $page, $activeRecordClass, $count, \IPS\Member $authorizedMember = NULL, $perPage=NULL )
{
$this->httpCode = $httpCode;
$this->page = (int) $page;
$page = (int) $page;
$this->page = $page > 0 ? $page : 1;
$this->iterator = $iterator;
$this->activeRecordClass = $activeRecordClass;
$this->count = (int) $count;
+69 -75
View File
@@ -786,7 +786,6 @@ EOF;
$classes = array();
$jsonFile = $this->getApplicationPath() . "/data/extensions.json";
$directory = $this->getApplicationPath() . "/extensions/{$app}/{$extension}";
/* New extensions.json based approach */
if ( file_exists( $jsonFile ) and $json = @json_decode( \file_get_contents( $jsonFile ), TRUE ) )
@@ -814,56 +813,6 @@ EOF;
}
}
}
/* Legacy DirectoryIterator approach */
elseif ( is_dir( $directory ) )
{
$dir = new \DirectoryIterator( $directory );
foreach ( $dir as $file )
{
/* Macs create copies of files with "._" prefix which breaks when we just load up all files in a dir, ignore those */
if ( !$file->isDir() and !$file->isDot() and mb_substr( $file, -4 ) === '.php' AND mb_substr( $file, 0, 2 ) != '._' )
{
$classname = 'IPS\\' . $this->directory . '\extensions\\' . $app . '\\' . $extension . '\\' . mb_substr( $file, 0, -4 );
/* Check if class exists - sometimes we have to use blank files to wipe out old extensions */
try
{
if( !class_exists( $classname ) )
{
continue;
}
if ( method_exists( $classname, 'deprecated' ) )
{
continue;
}
}
catch( \ErrorException $e )
{
continue;
}
if ( method_exists( $classname, 'generate' ) )
{
$classes = array_merge( $classes, $classname::generate() );
}
elseif ( !$construct )
{
$classes[ mb_substr( $file, 0, -4 ) ] = $classname;
}
else
{
try
{
$classes[ mb_substr( $file, 0, -4 ) ] = new $classname( $checkAccess === TRUE ? \IPS\Member::loggedIn() : ( $checkAccess === FALSE ? NULL : $checkAccess ) );
}
catch( \RuntimeException $e ){}
}
}
}
}
return $classes;
}
@@ -1018,7 +967,7 @@ EOF;
);
/* Upgrade */
if( !$this->protected AND !\IPS\DEMO_MODE AND $this->marketplace_id === NULL AND \IPS\IPS::canManageResources() AND \IPS\IPS::checkThirdParty() )
if( !$this->protected AND !\IPS\DEMO_MODE AND \IPS\IPS::canManageResources() )
{
$buttons['upgrade'] = array(
'icon' => 'upload',
@@ -1054,28 +1003,25 @@ EOF;
$buttons['edit'] = $edit;
}
if( !$this->marketplace_id )
{
$buttons['compilejs'] = array(
'icon' => 'cog',
'title' => 'app_compile_js',
'link' => \IPS\Http\Url::internal( "app=core&module=applications&controller=applications&appKey={$this->_id}&do=compilejs" )->csrf()
);
$buttons['compilejs'] = array(
'icon' => 'cog',
'title' => 'app_compile_js',
'link' => \IPS\Http\Url::internal( "app=core&module=applications&controller=applications&appKey={$this->_id}&do=compilejs" )->csrf()
);
$buttons['build'] = array(
'icon' => 'cog',
'title' => 'app_build',
'link' => \IPS\Http\Url::internal("app=core&module=applications&controller=applications&appKey={$this->_id}&do=build"),
'data' => array('ipsDialog' => '', 'ipsDialog-title' => \IPS\Member::loggedIn()->language()->addToStack('app_build'))
);
$buttons['build'] = array(
'icon' => 'cog',
'title' => 'app_build',
'link' => \IPS\Http\Url::internal("app=core&module=applications&controller=applications&appKey={$this->_id}&do=build"),
'data' => array('ipsDialog' => '', 'ipsDialog-title' => \IPS\Member::loggedIn()->language()->addToStack('app_build'))
);
$buttons['export'] = array(
'icon' => 'download',
'title' => 'download',
'link' => \IPS\Http\Url::internal("app=core&module=applications&controller=applications&appKey={$this->_id}&do=download"),
'data' => array('ipsDialog' => '', 'ipsDialog-title' => \IPS\Member::loggedIn()->language()->addToStack('download'), 'ipsDialog-remoteVerify' => 'false')
);
}
$buttons['export'] = array(
'icon' => 'download',
'title' => 'download',
'link' => \IPS\Http\Url::internal("app=core&module=applications&controller=applications&appKey={$this->_id}&do=download"),
'data' => array('ipsDialog' => '', 'ipsDialog-title' => \IPS\Member::loggedIn()->language()->addToStack('download'), 'ipsDialog-remoteVerify' => 'false')
);
$buttons['developer'] = array(
'icon' => 'cogs',
@@ -2787,7 +2733,11 @@ EOF;
{
$db = \IPS\Db::i();
$changesToMake = array();
/* If member IDs are getting near the legacy mediumint limit, we need to increase it. */
$maxMemberId = \IPS\Db::i()->select( 'max(member_id)', 'core_members' )->first();
$enableBigInt = ( $maxMemberId > 8288607 );
/* Loop the tables in the schema */
foreach( json_decode( file_get_contents( $this->getApplicationPath() . "/data/schema.json" ), TRUE ) as $tableName => $tableDefinition )
{
@@ -2861,7 +2811,7 @@ EOF;
$differences[ $k ] = array( 'is' => $localDefinition['columns'][ $columnName ][ $k ], 'shouldBe' => $v );
}
}
if ( isset( $differences['type'] ) and ( $differences['type']['is'] == 'MEDIUMINT' or $differences['type']['is'] == 'INT' ) and $differences['type']['shouldBe'] == 'BIGINT' )
if ( isset( $differences['type'] ) and ( $differences['type']['is'] == 'MEDIUMINT' or $differences['type']['is'] == 'INT' ) and $differences['type']['shouldBe'] == 'BIGINT' AND !$enableBigInt )
{
unset( $differences['type'] );
if ( isset( $differences['length'] ) )
@@ -3865,7 +3815,13 @@ EOF;
/* End */
$xml->endElement();
/* Enforce \n line endings */
if( mb_strtolower( mb_substr( PHP_OS, 0, 3 ) ) === 'win' )
{
$v = str_replace( "\r\n", "\n", $v );
}
/* Note it */
if ( !isset( $current['0'][ $k ] ) )
{
@@ -3912,6 +3868,12 @@ EOF;
/* End */
$xml->endElement();
/* Enforce \n line endings */
if( mb_strtolower( mb_substr( PHP_OS, 0, 3 ) ) === 'win' )
{
$v = str_replace( "\r\n", "\n", $v );
}
/* Note it */
if ( !isset( $current['1'][ $k ] ) )
@@ -4776,6 +4738,7 @@ EOF;
\IPS\Db::i()->delete( 'core_queue', array( 'app=?', $this->directory ) );
\IPS\Db::i()->delete( 'core_follow', array( 'follow_app=?', $this->directory ) );
\IPS\Db::i()->delete( 'core_follow_count_cache', array( "class LIKE CONCAT( ?, '%' )", "IPS\\\\{$this->directory}" ) );
\IPS\Db::i()->delete( 'core_item_statistics_cache', array( "cache_class LIKE CONCAT( ?, '%' )", "IPS\\\\{$this->directory}" ) );
\IPS\Db::i()->delete( 'core_view_updates', array( "classname LIKE CONCAT( ?, '%' )", "IPS\\\\{$this->directory}" ) );
\IPS\Db::i()->delete( 'core_moderator_logs', array( 'appcomponent=?', $this->directory ) );
\IPS\Db::i()->delete( 'core_member_history', array( 'log_app=?', $this->directory ) );
@@ -5448,4 +5411,35 @@ EOF;
{
return NULL;
}
/**
* Returns a list of all essential cookies which are set by all the installed apps
* To return a list of own cookies, use the @see \IPS\Application::_getEssentialCookieNames() method.
*
* @return string[]
*/
public final static function getEssentialCookieNames(): array
{
if ( !isset( \IPS\Data\Store::i()->essentialCookieNames ) )
{
$names = [];
foreach( static::applications() as $app )
{
$names = array_merge( $names, $app->_getEssentialCookieNames() );
}
\IPS\Data\Store::i()->essentialCookieNames = $names;
}
return \IPS\Data\Store::i()->essentialCookieNames;
}
/**
* Returns a list of essential cookies which are set by this app.
* Wildcards (*) can be used at the end of cookie names for PHP set cookies.
*
* @return string[]
*/
public function _getEssentialCookieNames(): array
{
return [];
}
}
+1 -1
View File
@@ -129,7 +129,7 @@ class _ItemController extends \IPS\Api\Controller
}
else
{
$where[] = array( $class::$databasePrefix . $class::$databaseColumnMap['poll'] . "=0" );
$where[] = array( $class::$databasePrefix . $class::$databaseColumnMap['poll'] . " IS NULL" );
}
}
+17 -5
View File
@@ -240,7 +240,10 @@ abstract class _Comment extends \IPS\Content
if ( !$obj->hidden() and ( !$first or !$item::$firstCommentRequired ) )
{
$obj->sendNotifications();
$member->achievementAction( 'core', 'Comment', $obj );
if( !$item instanceof \IPS\core\Messenger\Conversation )
{
$member->achievementAction( 'core', 'Comment', $obj );
}
}
else if( $obj->hidden() === 1 )
{
@@ -467,7 +470,7 @@ abstract class _Comment extends \IPS\Content
{
\IPS\Redis::i()->zIncrBy( 'trending', time(), $class . '__' . $item->$itemIdColumn );
}
catch( \RedisException $e ) { }
catch( \BadMethodCallException | \RedisException $e ) { }
}
/* Was it moderated? Let's see why. */
@@ -626,7 +629,7 @@ abstract class _Comment extends \IPS\Content
return FALSE;
}
if ( $member->member_id )
if ( $member->member_id AND $this->canView( $member ) )
{
$item = $this->item();
@@ -1112,6 +1115,9 @@ abstract class _Comment extends \IPS\Content
{
\IPS\core\IndexNow::addUrlsToQueue( $noIndexUrls );
}
/* Remove from Hive */
\IPS\core\Hive::i()->removeContent( $this );
}
/**
@@ -1264,7 +1270,7 @@ abstract class _Comment extends \IPS\Content
/* Update last comment/review data for container and item */
try
{
if ( $item !== NULL AND \in_array( 'IPS\Content\Review', class_parents( \get_called_class() ) ) )
if ( $item !== NULL AND $item->container() !== NULL AND \in_array( 'IPS\Content\Review', class_parents( \get_called_class() ) ) )
{
if ( $item->container()->_reviews !== NULL )
{
@@ -1300,7 +1306,7 @@ abstract class _Comment extends \IPS\Content
}
/* Update mappings */
if ( \IPS\IPS::classUsesTrait( $item->container(), 'IPS\Node\Statistics' ) )
if ( $item !== NULL AND $item->container() !== NULL AND \IPS\IPS::classUsesTrait( $item->container(), 'IPS\Node\Statistics' ) )
{
$item->container()->rebuildPostedIn( array( $item->$itemIdColumn ), array( $this->author() ) );
}
@@ -1556,6 +1562,12 @@ abstract class _Comment extends \IPS\Content
}
}
/* Finally, we do not want posts from future items */
if ( \in_array( 'IPS\Content\FuturePublishing', class_implements( $itemClass ) ) )
{
$itemWhere[] = array( $itemClass::$databasePrefix . $itemClass::$databaseColumnMap['is_future_entry'] . '=0' );
}
if ( $joinContainer AND isset( $itemClass::$containerNodeClass ) )
{
$containerClass = $itemClass::$containerNodeClass;
+40 -7
View File
@@ -411,6 +411,9 @@ abstract class _Content extends \IPS\Patterns\ActiveRecord
}
catch( \OutOfRangeException $e ) { }
/* Remove from Hive */
\IPS\core\Hive::i()->removeContent( $this );
parent::delete();
$this->expireWidgetCaches();
@@ -1210,10 +1213,13 @@ abstract class _Content extends \IPS\Patterns\ActiveRecord
}
}
\IPS\core\Approval::loadFromContent( \get_called_class(), $this->$idColumn )->delete();
\IPS\core\Approval::loadFromContent( \get_called_class(), $this->$idColumn )->delete();
}
catch( \BadMethodCallException $e ) {}
catch( \OutOfRangeException $e ) {}
/* Remove from Hive */
\IPS\core\Hive::i()->removeContent( $this );
}
/**
@@ -1387,6 +1393,9 @@ abstract class _Content extends \IPS\Patterns\ActiveRecord
\IPS\core\Approval::loadFromContent( \get_called_class(), $this->$idColumn )->delete();
}
catch( \OutOfRangeException $e ) { }
/* Remove from Hive */
\IPS\core\Hive::i()->removeContent( $this );
}
/**
@@ -1876,7 +1885,22 @@ abstract class _Content extends \IPS\Patterns\ActiveRecord
foreach ( \IPS\Db::i()->select( '*', 'core_members', ( \count( $moderators['m'] ) ? \IPS\Db::i()->in( 'member_id', $moderators['m'] ) . ' OR ' : '' ) . \IPS\Db::i()->in( 'member_group_id', $moderators['g'] ) . ' OR ' . \IPS\Db::i()->findInSet( 'mgroup_others', $moderators['g'] ) ) as $mem )
{
$memberObj = \IPS\Member::constructFromData( $mem );
if( $this->canView( $memberObj ) )
/* Members may have individual member level mod permissions, but are also in a group that has moderator permissions. In this case, the member level restrictions always win so we need to recheck those now that we have a member object. See \IPS\Member::modPermissions(). */
$perms = $memberObj->modPermissions();
$canView = $this->canView( $memberObj );
if ( $canView === TRUE and $container = $item->containerWrapper() and isset( $container::$modPerm ) )
{
if ( isset( $perms[ $container::$modPerm ] ) and $perms[ $container::$modPerm ] != '*' and $perms[ $container::$modPerm ] != -1 )
{
if ( empty( $perms[ $container::$modPerm ] ) or ! in_array( $item->mapped('container'), $perms[ $container::$modPerm ] ) )
{
$canView = FALSE;
}
}
}
if( $canView === TRUE )
{
$notification->recipients->attach( $memberObj );
}
@@ -2465,7 +2489,7 @@ abstract class _Content extends \IPS\Patterns\ActiveRecord
}
/* Queue if there's lots, or just send them */
if ( $count > static::NOTIFICATIONS_PER_BATCH )
if ( $count > \IPS\NOTIFICATION_BACKGROUND_THRESHOLD )
{
$idColumn = $this::$databaseColumnId;
\IPS\Task::queue( 'core', 'Follow', array( 'class' => \get_class( $this ), 'item' => $this->$idColumn, 'sentTo' => $sentTo, 'followerCount' => $count ), 2 );
@@ -3030,11 +3054,12 @@ abstract class _Content extends \IPS\Patterns\ActiveRecord
/**
* Log keyword usage, if any
*
* @param string $content Content/text of submission
* @param string|NULL $title Title of submission
* @param string $content Content/text of submission
* @param string|NULL $title Title of submission
* @param int|NULL $date Date of submission
* @return void
*/
public function checkKeywords( $content, $title=NULL )
public function checkKeywords( $content, $title=NULL, ?int $date = NULL )
{
/* Do we have any keywords to track? */
if( !\IPS\Settings::i()->stats_keywords )
@@ -3078,7 +3103,8 @@ abstract class _Content extends \IPS\Patterns\ActiveRecord
{
if( \in_array( $keyword, $words ) )
{
\IPS\Db::i()->insert( 'core_statistics', array( 'time' => time(), 'type' => 'keyword', 'value_4' => $keyword, 'extra_data' => $extraData ) );
$date = $date ?: \IPS\DateTime::create()->getTimestamp();
\IPS\Db::i()->insert( 'core_statistics', array( 'time' => $date, 'type' => 'keyword', 'value_4' => $keyword, 'extra_data' => $extraData ) );
}
}
}
@@ -3149,6 +3175,12 @@ abstract class _Content extends \IPS\Patterns\ActiveRecord
{
if ( $this instanceof \IPS\Content\EditHistory and $this->mapped('edit_time') and ( $this->mapped('edit_show') or \IPS\Member::loggedIn()->modPermission('can_view_editlog') ) and \IPS\Settings::i()->edit_log )
{
/* If anonymous and we can't see, it's a no. */
if ( $this->isAnonymous() AND !\IPS\Member::loggedIn()->modPermission('can_view_anonymous_posters') )
{
return NULL;
}
$template = static::$editLineTemplate[1];
return \IPS\Theme::i()->getTemplate( static::$editLineTemplate[0][0], static::$editLineTemplate[0][1], ( isset( static::$editLineTemplate[0][2] ) ) ? static::$editLineTemplate[0][2] : NULL )->$template( $this, ( isset( static::$databaseColumnMap['edit_reason'] ) and $this->mapped('edit_reason') ) );
}
@@ -3287,4 +3319,5 @@ abstract class _Content extends \IPS\Patterns\ActiveRecord
{
return [];
}
}
+134 -49
View File
@@ -104,6 +104,16 @@ class _Controller extends \IPS\Helpers\CoverPhoto\Controller
catch( \OutOfRangeException $e ) { }
}
}
if( is_subclass_of( $class, 'IPS\Content\FuturePublishing' ) and isset( $class::$databaseColumnMap['is_future_entry'] ) AND isset( $class::$databaseColumnMap['future_date'] ) )
{
$futureColumn = $class::$databaseColumnMap['future_date'];
$futureEntry = $class::$databaseColumnMap['is_future_entry'];
if ( $item->$futureEntry == 1 and $item->$futureColumn <= time() )
{
$item->publish();
}
}
/* If this is an AJAX request (like the topic hovercard), we don't want to do any of the below like update views and mark read */
if ( \IPS\Request::i()->isAjax() and !$this->updateViewsAndMarkersOnAjax )
@@ -117,27 +127,33 @@ class _Controller extends \IPS\Helpers\CoverPhoto\Controller
/* We also want to update the views if we have a page parameter */
if( $item instanceof \IPS\Content\ReadMarkers AND isset( \IPS\Request::i()->page ) AND \IPS\Request::i()->page )
{
$idColumn = $class::$databaseColumnId;
if ( \in_array( 'IPS\Content\Views', class_implements( $class ) ) AND isset( $class::$databaseColumnMap['views'] ) )
if ( \IPS\IPS::classUsesTrait( $class, 'IPS\Content\ViewUpdates' ) )
{
$countUpdated = false;
if ( \IPS\REDIS_ENABLED and \IPS\CACHE_METHOD == 'Redis' and ( \IPS\CACHE_CONFIG or \IPS\REDIS_CONFIG ) )
$item->updateViews();
}
else
{
$idColumn = $class::$databaseColumnId;
if ( \in_array( 'IPS\Content\Views', class_implements( $class ) ) AND isset( $class::$databaseColumnMap['views'] ) )
{
try
$countUpdated = false;
if ( \IPS\REDIS_ENABLED and \IPS\CACHE_METHOD == 'Redis' and ( \IPS\CACHE_CONFIG or \IPS\REDIS_CONFIG ) )
{
\IPS\Redis::i()->zIncrBy( 'topic_views', 1, $class .'__' . $item->$idColumn );
$countUpdated = true;
try
{
\IPS\Redis::i()->zIncrBy( 'topic_views', 1, $class .'__' . $item->$idColumn );
$countUpdated = true;
}
catch( \Exception $e ) {}
}
if ( ! $countUpdated )
{
\IPS\Db::i()->insert( 'core_view_updates', array(
'classname' => $class,
'id' => $item->$idColumn
) );
}
catch( \Exception $e ) {}
}
if ( ! $countUpdated )
{
\IPS\Db::i()->insert( 'core_view_updates', array(
'classname' => $class,
'id' => $item->$idColumn
) );
}
}
}
@@ -231,36 +247,43 @@ class _Controller extends \IPS\Helpers\CoverPhoto\Controller
/* Update Views */
$idColumn = $class::$databaseColumnId;
if ( \in_array( 'IPS\Content\Views', class_implements( $class ) ) AND isset( $class::$databaseColumnMap['views'] ) )
if ( \IPS\IPS::classUsesTrait( $class, 'IPS\Content\ViewUpdates' ) )
{
$countUpdated = false;
if ( \IPS\REDIS_ENABLED and \IPS\CACHE_METHOD == 'Redis' and ( \IPS\CACHE_CONFIG or \IPS\REDIS_CONFIG ) )
$item->updateViews();
}
else
{
if ( \in_array( 'IPS\Content\Views', class_implements( $class ) ) AND isset( $class::$databaseColumnMap['views'] ) )
{
try
$countUpdated = false;
if ( \IPS\REDIS_ENABLED and \IPS\CACHE_METHOD == 'Redis' and ( \IPS\CACHE_CONFIG or \IPS\REDIS_CONFIG ) )
{
\IPS\Redis::i()->zIncrBy( 'topic_views', 1, $class .'__' . $item->$idColumn );
$countUpdated = true;
try
{
\IPS\Redis::i()->zIncrBy( 'topic_views', 1, $class .'__' . $item->$idColumn );
$countUpdated = true;
}
catch( \Exception $e ) {}
}
catch( \Exception $e ) {}
}
if( \IPS\Application::appIsEnabled( 'cloud' ) and \IPS\cloud\Realtime::i()->isEnabled('trending') and $class::$includeInTrending )
{
try
if( \IPS\Application::appIsEnabled( 'cloud' ) and \IPS\cloud\Realtime::i()->isEnabled('trending') and $class::$includeInTrending )
{
/* Score by timestamp for trending */
\IPS\Redis::i()->zIncrBy( 'trending', time() * 0.1, $class .'__' . $item->$idColumn );
try
{
/* Score by timestamp for trending */
\IPS\Redis::i()->zIncrBy( 'trending', time() * 0.1, $class .'__' . $item->$idColumn );
}
catch( \BadMethodCallException | \RedisException $e ) {}
}
if ( ! $countUpdated )
{
\IPS\Db::i()->insert( 'core_view_updates', array(
'classname' => $class,
'id' => $item->$idColumn
) );
}
catch( \RedisException $e ) {}
}
if ( ! $countUpdated )
{
\IPS\Db::i()->insert( 'core_view_updates', array(
'classname' => $class,
'id' => $item->$idColumn
) );
}
}
@@ -361,7 +384,7 @@ class _Controller extends \IPS\Helpers\CoverPhoto\Controller
\IPS\Output::i()->redirect( $class::getRedirectFrom( \IPS\Request::i()->id )->url(), '', 301 );
}
catch( \OutOfRangeException $e ) { }
return NULL;
}
}
@@ -1000,6 +1023,7 @@ class _Controller extends \IPS\Helpers\CoverPhoto\Controller
}
else
{
\IPS\Output::i()->bypassCsrfKeyCheck = TRUE;
\IPS\Output::i()->output = $form->customTemplate( array( \IPS\Theme::i()->getTemplate( 'forms', 'core' ), 'popupTemplate' ) );
return;
}
@@ -1628,7 +1652,7 @@ class _Controller extends \IPS\Helpers\CoverPhoto\Controller
}
/**
* Moderator Log
* Moderation Log
*
* @return void
*/
@@ -1643,11 +1667,65 @@ class _Controller extends \IPS\Helpers\CoverPhoto\Controller
{
$class = static::$contentModel;
$item = $class::loadAndCheckPerms( \IPS\Request::i()->id );
/* Set up some stuff so we're not doing too much logic / assignment in the template */
$modlog = $item->moderationTable();
\IPS\Output::i()->output = \IPS\Theme::i()->getTemplate( 'global', 'core', 'front' )->modLog( $item, $modlog );
}
catch ( \OutOfRangeException $e )
{
\IPS\Output::i()->error( 'node_error', '2S136/T', 404, '' );
}
catch( \DomainException $e )
{
\IPS\Output::i()->error( 'no_module_permission', '2S136/U', 403, '' );
}
}
/**
* Moderation Log
*
* @return void
*/
protected function analytics()
{
if( !\IPS\Member::loggedIn()->modPermission( 'can_view_moderation_log' ) )
{
\IPS\Output::i()->error( 'no_module_permission', '2S136/1F', 403, '' );
}
try
{
$class = static::$contentModel;
$item = $class::loadAndCheckPerms( \IPS\Request::i()->id );
/* Set up some stuff so we're not doing too much logic / assignment in the template */
$lastCommenter = $members = $busy = $reacted = $images = NULL;
try
{
$lastCommenter = $item->lastCommenter();
if ( !$lastCommenter->member_id )
{
$lastCommenter = NULL;
}
}
catch( \BadMethodCallException $e ) { }
if ( \IPS\IPS::classUsesTrait( $item, 'IPS\Content\Statistics' ) )
{
$members = $item->topPosters();
$busy = $item->popularDays();
$reacted = $item->topReactedPosts();
$images = $item->imageAttachments();
}
/* Set navigation */
$this->_setBreadcrumbAndTitle( $item );
\IPS\Output::i()->output = $item->moderationTable();
\IPS\Output::i()->breadcrumb[] = array( NULL, \IPS\Member::loggedIn()->language()->addToStack( 'analytics_and_stats' ) );
\IPS\Output::i()->output = \IPS\Theme::i()->getTemplate( 'global', 'core', 'front' )->analytics( $item, $lastCommenter, $members, $busy, $reacted, $images );
}
catch ( \OutOfRangeException $e )
{
@@ -1967,9 +2045,11 @@ class _Controller extends \IPS\Helpers\CoverPhoto\Controller
if ( $values = $form->values() )
{
$review->author_response = $values['reviewResponse'];
$review->save();
$review->setResponse( $values['reviewResponse'] );
/* Claim attachments and clear the editor */
\IPS\File::claimAttachments( 'reviewResponse-' . $class::$application . '/' . $class::$module . '-' . \IPS\Request::i()->review, \IPS\Request::i()->id, \IPS\Request::i()->review, \get_class( $item ) );
\IPS\Output::i()->redirect( $review->url() );
}
@@ -4008,9 +4088,14 @@ class _Controller extends \IPS\Helpers\CoverPhoto\Controller
return NULL;
}
$class = static::$contentModel;
if( !\is_subclass_of( $class, 'IPS\Content\Item' ) )
{
return NULL;
}
try
{
$class = static::$contentModel;
$item = $class::loadAndCheckPerms( \IPS\Request::i()->id );
$items = [];
+110 -52
View File
@@ -41,6 +41,13 @@ abstract class _Item extends \IPS\Content
* @brief [Content\Item] Follower count
*/
public $followerCount = NULL;
/**
* Should IndexNow be skipped for this item? Can be used to prevent that Private Messages,
* Reports and other content which is never going to be visible to guests is triggering the requests.
* @var bool
*/
public static bool $skipIndexNow = FALSE;
/**
* @brief [Content\Item] If $firstCommentRequired is TRUE, when comments are split from an item or items are merged, the author
@@ -521,7 +528,7 @@ abstract class _Item extends \IPS\Content
}
/* Dish out points */
if ( !$obj->isFutureDate() and !$obj->hidden() )
if ( !$obj->isFutureDate() and !$obj->hidden() and !$obj instanceof \IPS\core\Messenger\Conversation )
{
$member->achievementAction( 'core', 'NewContentItem', $obj );
}
@@ -619,6 +626,12 @@ abstract class _Item extends \IPS\Content
{
$column = static::$databaseColumnMap['future_date'];
$this->$column = $time->getTimestamp();
if( isset( static::$databaseColumnMap['is_future_entry'] ) )
{
$column = static::$databaseColumnMap['is_future_entry'];
$this->$column = $time->getTimestamp() > time();
}
}
}
@@ -718,7 +731,12 @@ abstract class _Item extends \IPS\Content
/* Title */
if ( isset( static::$databaseColumnMap['title'] ) )
{
$return['title'] = new \IPS\Helpers\Form\Text( static::$formLangPrefix . 'title', isset( \IPS\Request::i()->title ) ? \IPS\Request::i()->title : $item?->mapped( 'title' ), TRUE, array( 'maxLength' => \IPS\Settings::i()->max_title_length ?: 255, 'bypassProfanity' => \IPS\Helpers\Form\Text::BYPASS_PROFANITY_SWAP ) );
$return['title'] = new \IPS\Helpers\Form\Text( static::$formLangPrefix . 'title', isset( \IPS\Request::i()->title ) ? \IPS\Request::i()->title : $item?->mapped( 'title' ), TRUE, array( 'maxLength' => \IPS\Settings::i()->max_title_length ?: 255, 'bypassProfanity' => \IPS\Helpers\Form\Text::BYPASS_PROFANITY_SWAP ), function( $val ) {
if ( !\IPS\Member::loggedIn()->group['g_bypass_badwords'] AND $word = \IPS\core\Profanity::checkProfanityBlocks( $val ) )
{
throw new \DomainException( \IPS\Member::loggedIn()->language()->addToStack( "form_err_word_blocked", FALSE, array( "sprintf" => array( $word ) ) ) );
}
} );
$return['title']->rowClasses[] = 'ipsFieldRow_primary';
$return['title']->rowClasses[] = 'ipsFieldRow_fullWidth';
}
@@ -1015,6 +1033,10 @@ abstract class _Item extends \IPS\Content
{
$options['autocomplete']['filterProfanity'] = TRUE;
}
if ( \IPS\Settings::i()->tags_alphabetical )
{
$options['autocomplete']['alphabetical'] = TRUE;
}
$options['autocomplete']['prefix'] = static::canPrefix( NULL, $container );
$options['autocomplete']['disallowedCharacters'] = array( '#' ); // @todo Pending \IPS\Http\Url rework, hashes cannot be used in URLs
@@ -1149,7 +1171,7 @@ abstract class _Item extends \IPS\Content
}
/* Send this URL to IndexNow if the guest can view it */
if ( $this->canView( new \IPS\Member ) )
if ( $this->canView( new \IPS\Member ) AND !static::$skipIndexNow )
{
\IPS\core\IndexNow::addUrlToQueue( $this->url() );
}
@@ -1330,6 +1352,14 @@ abstract class _Item extends \IPS\Content
$this->publish();
}
}
else if ( $container and ! $this->$column )
{
if ( $values[ static::$formLangPrefix . 'date' ] instanceof \IPS\DateTime AND $values[ static::$formLangPrefix . 'date' ]->getTimestamp() > time() )
{
/* Was not future, now is */
$this->unpublish();
}
}
}
/* Post anonymously */
@@ -1903,7 +1933,7 @@ abstract class _Item extends \IPS\Content
}
}
if ( $this instanceof \IPS\Content\Views )
if ( \IPS\IPS::classUsesTrait( $this, 'IPS\Content\ViewUpdates' ) )
{
$return['num_views'] = (int) $this->mapped('views');
}
@@ -2129,6 +2159,9 @@ abstract class _Item extends \IPS\Content
{
$link->markRead();
}
/* Remove from Hive */
\IPS\core\Hive::i()->removeContent( $this );
}
/**
@@ -2275,7 +2308,7 @@ abstract class _Item extends \IPS\Content
}
/* Merge view counts */
if ( $this instanceof \IPS\Content\Views )
if ( \IPS\IPS::classUsesTrait( $this, 'IPS\Content\ViewUpdates' ) )
{
$views += $item->mapped('views');
\IPS\Db::i()->update( 'core_view_updates', array( 'id' => $this->$idColumn ), array( 'classname=? and id=?', (string) \get_class( $item ), $item->$idColumn ) );
@@ -2725,7 +2758,7 @@ abstract class _Item extends \IPS\Content
}
$idColumn = static::$databaseColumnId;
/* Don't do anything for shadow items */
if ( isset( static::$databaseColumnMap['moved_to'] ) )
{
@@ -2740,19 +2773,19 @@ abstract class _Item extends \IPS\Content
}
\IPS\Db::i()->delete( 'core_item_member_map', array( 'map_class=? and map_item_id=?', (string) \get_class( $this ), (int) $this->$idColumn ) );
/* Remove any meta data */
if ( ( $this instanceof \IPS\Content\MetaData ) AND isset( static::$databaseColumnMap['meta_data'] ) )
{
$this->deleteAllMeta();
}
/* Unclaim attachments */
$this->unclaimAttachments();
/* Delete it from the database */
parent::delete();
/* Update count */
try
{
@@ -2772,7 +2805,7 @@ abstract class _Item extends \IPS\Content
}
}
} catch ( \BadMethodCallException $e ) {}
/* Delete comments */
if ( isset( static::$commentClass ) )
{
@@ -2783,7 +2816,7 @@ abstract class _Item extends \IPS\Content
{
$where = $commentClass::deleteWhereSql( $this->$idColumn );
}
/* Remove any deletion logs for comments */
$commentIds = array();
$commentIdColumn = $commentClass::$databasePrefix . $commentClass::$databaseColumnId;
@@ -2791,14 +2824,14 @@ abstract class _Item extends \IPS\Content
{
$commentIds[] = $commentId;
}
$this->deleteCommentOrReviewData( $commentIds, $commentClass );
if ( \is_array( $where ) and \count( $where ) )
{
\IPS\Db::i()->delete( $commentClass::$databaseTable, $where );
}
if( !$this->skipContainerRebuild )
{
try
@@ -2810,7 +2843,7 @@ abstract class _Item extends \IPS\Content
{
$this->container()->_comments = ( $this->container()->_comments - $this->mapped('num_comments') );
}
$this->container()->setLastComment();
}
if ( $this->container()->_unapprovedComments !== NULL )
@@ -2821,7 +2854,7 @@ abstract class _Item extends \IPS\Content
} catch ( \BadMethodCallException $e ) {}
}
}
/* Delete reviews */
if ( isset( static::$reviewClass ) )
{
@@ -2832,7 +2865,7 @@ abstract class _Item extends \IPS\Content
{
$where = $reviewClass::deleteWhereSql( $this->$idColumn );
}
/* Remove any deletion logs for reviews */
$reviewIds = array();
$reviewIdColumn = $reviewClass::$databasePrefix . $reviewClass::$databaseColumnMap['item'];
@@ -2844,7 +2877,7 @@ abstract class _Item extends \IPS\Content
$this->deleteCommentOrReviewData( $reviewIds, $reviewClass );
\IPS\Db::i()->delete( $reviewClass::$databaseTable, $where );
if( !$this->skipContainerRebuild )
{
try
@@ -2856,7 +2889,7 @@ abstract class _Item extends \IPS\Content
{
$this->container()->_reviews = ( $this->container()->_reviews - $this->mapped('num_reviews') );
}
$this->container()->setLastReview();
}
if ( $this->container()->_unapprovedReviews !== NULL )
@@ -2867,7 +2900,7 @@ abstract class _Item extends \IPS\Content
} catch ( \BadMethodCallException $e ) {}
}
}
/* Delete tags */
if ( $this instanceof \IPS\Content\Tags )
{
@@ -2876,7 +2909,7 @@ abstract class _Item extends \IPS\Content
\IPS\Db::i()->delete( 'core_tags_cache', array( 'tag_cache_key=?', $aaiLookup ) );
\IPS\Db::i()->delete( 'core_tags_perms', array( 'tag_perm_aai_lookup=?', $aaiLookup ) );
}
/* Delete follows */
if ( $this instanceof \IPS\Content\Followable )
{
@@ -2884,7 +2917,7 @@ abstract class _Item extends \IPS\Content
\IPS\Db::i()->delete( 'core_follow', array( 'follow_app=? AND follow_area=? AND follow_rel_id=?', static::$application, $followArea, (int) $this->$idColumn ) );
\IPS\Db::i()->delete( 'core_follow_count_cache', array( 'class=? AND id=?', \get_called_class(), (int) $this->$idColumn ) );
}
/* Remove Notifications */
$memberIds = array();
@@ -2894,13 +2927,13 @@ abstract class _Item extends \IPS\Content
}
\IPS\Db::i()->delete( 'core_notifications', array( 'item_class=? AND item_id=?', (string) \get_class( $this ), (int) $this->$idColumn ) );
/* Delete from Our Picks */
\IPS\Db::i()->delete( 'core_social_promote', array( 'promote_class=? AND promote_class_id=?', (string) \get_class( $this ), (int) $this->$idColumn ) );
/* Delete from redirect links */
\IPS\Db::i()->delete( 'core_item_redirect', array( 'redirect_class=? AND redirect_new_item_id=?', (string) \get_class( $this ), (int) $this->$idColumn ) );
/* Delete Polls */
if ( $this instanceof \IPS\Content\Polls and $this->getPoll() )
{
@@ -2912,19 +2945,21 @@ abstract class _Item extends \IPS\Content
{
\IPS\Db::i()->delete( 'core_ratings', array( 'class=? AND item_id=?', \get_called_class(), $this->$idColumn ) );
}
foreach( $memberIds as $member )
{
\IPS\Member::load( $member )->recountNotifications();
}
/** Item::url() can throw a LogicException exception in specific cases like when a Pages Record has no valid page */
try
if( !static::$skipIndexNow )
{
\IPS\core\IndexNow::addUrlToQueue( $this->url() );
try
{
\IPS\core\IndexNow::addUrlToQueue( $this->url() );
}
catch( \LogicException $e ) {}
}
catch( \LogicException $e ) {}
}
/**
@@ -3174,7 +3209,7 @@ abstract class _Item extends \IPS\Content
if ( isset( static::$databaseColumnMap['approved'] ) )
{
$col = static::$databaseTable . '.' . static::$databasePrefix . static::$databaseColumnMap['approved'];
if ( $member->member_id and $includeHiddenItems !== \IPS\Content\Hideable::FILTER_PUBLIC_ONLY )
if ( $member->member_id and $includeHiddenItems !== \IPS\Content\Hideable::FILTER_PUBLIC_ONLY and isset( static::$databaseColumnMap['author'] ) )
{
/* Only fetching a count, for a single container with a item cache count, no future publishing, and there is only one where clause (the container limitation) */
if( $countOnly === TRUE AND $skipPermission instanceof \IPS\Node\Model AND $skipPermission->_items !== NULL AND !\in_array( 'IPS\Content\FuturePublishing', class_implements( \get_called_class() ) ) AND \count( $where ) === 1 )
@@ -8069,7 +8104,13 @@ abstract class _Item extends \IPS\Content
*/
public static function definedTags( \IPS\Node\Model $container = NULL )
{
return \IPS\Settings::i()->tags_predefined ? explode( ',', \IPS\Settings::i()->tags_predefined ) : array();
$return = \IPS\Settings::i()->tags_predefined ? explode( ',', \IPS\Settings::i()->tags_predefined ) : array();
if ( \IPS\Settings::i()->tags_alphabetical )
{
natcasesort( $return );
}
return $return;
}
/**
@@ -8125,8 +8166,20 @@ abstract class _Item extends \IPS\Content
}
}
}
return ( isset ( $this->tags['tags'] ) ? $this->tags['tags'] : [] );
if ( isset( $this->tags['tags'] ) )
{
if ( \IPS\Settings::i()->tags_alphabetical )
{
natcasesort( $this->tags['tags'] );
}
return $this->tags['tags'];
}
else
{
return [];
}
}
else
{
@@ -8617,8 +8670,29 @@ abstract class _Item extends \IPS\Content
$this->$future = 0;
}
if ( isset( static::$databaseColumnMap['last_comment'] ) or isset( static::$databaseColumnMap['last_review'] ) )
{
$lastCommentField = static::$databaseColumnMap['last_comment'];
if ( \is_array( $lastCommentField ) )
{
foreach ( $lastCommentField as $column )
{
$this->$column = time();
}
}
else
{
$this->$lastCommentField = time();
}
if ( isset( static::$databaseColumnMap['last_review'] ) )
{
$lastReviewField = static::$databaseColumnMap['last_review'];
$this->$lastReviewField = time();
}
}
$this->save();
$this->onPublish( $member );
/* And update the tags perm cache */
if ( $this instanceof \IPS\Content\Tags )
@@ -8634,26 +8708,10 @@ abstract class _Item extends \IPS\Content
$comment->$date = time();
$comment->save();
if ( isset( static::$databaseColumnMap['last_comment'] ) )
{
$lastCommentField = static::$databaseColumnMap['last_comment'];
if ( \is_array( $lastCommentField ) )
{
foreach ( $lastCommentField as $column )
{
$this->$column = time();
}
}
else
{
$this->$lastCommentField = time();
}
$this->save();
}
}
$this->onPublish( $member );
/* Mark this item as read for the author */
$this->markRead( $this->author() );
+8 -1
View File
@@ -170,7 +170,7 @@ trait Reactable
{
\IPS\Redis::i()->zIncrBy( 'trending', time() * 0.4, \get_class( $item ) .'__' . $itemId );
}
catch( \RedisException $e ) {}
catch( \BadMethodCallException | \RedisException $e ) {}
}
}
@@ -270,6 +270,7 @@ trait Reactable
return FALSE;
}
/* Cannot react to guest content */
if ( !$owner->member_id )
{
return FALSE;
@@ -293,6 +294,12 @@ trait Reactable
return FALSE;
}
/* Unacknowledged warnings */
if ( $member->members_bitoptions['unacknowledged_warnings'] )
{
return FALSE;
}
/* Still here? All good. */
return TRUE;
}
+21
View File
@@ -650,4 +650,25 @@ abstract class _Review extends \IPS\Content\Comment
/* Nope, we cannot delete */
return FALSE;
}
/**
* Respond to a review
*
* @param string $response
* @return void
*/
public function setResponse( string $response )
{
$this->checkProfanityFilters( FALSE, (bool) $this->hasAuthorResponse(), $response );
$column = static::$databaseColumnMap['author_response'];
$this->$column = $response;
$this->save();
/* Reindex in case it was hidden by the profanity filters */
if ( $this instanceof \IPS\Content\Searchable )
{
\IPS\Content\Search\Index::i()->index( $this );
}
}
}
+1 -1
View File
@@ -396,7 +396,7 @@ class _Query extends \IPS\Content\Search\Query
{
$classes = array_merge( $object->classes, $classes );
if ( ! $object->similarContent )
if ( empty( $object->similarContent ) )
{
foreach( $object->classes as $class )
{
+6
View File
@@ -249,6 +249,12 @@ abstract class _Index extends \IPS\Patterns\Singleton
$isLastComment = 1;
}
/* If this comment is hidden, don't actually mark as the last comment as that will cause this item to be hidden in search if we are getting only the last comment. */
if ( $isLastComment AND $object->hidden() )
{
$isLastComment = 0;
}
/* If we are re-indexing the first post of an item, which is triggered by a comment being added to a $itemClass::$firstCommentRequired,
then ensure that the isLastComment flag is not added as we index the comment first, which means the index_is_last_comment is incorrectly reset to 0 for the comment itself */
if ( $isForItem and $isLastComment )
+29 -6
View File
@@ -588,16 +588,39 @@ class _Index extends \IPS\Content\Search\Index
public function resetLastComment( $classes, $indexItemId, $ignoreId = NULL )
{
\IPS\Db::i()->update( 'core_search_index', array( 'index_is_last_comment' => 0 ), array( \IPS\Db::i()->in( 'index_class', $classes ) . ' AND index_item_id=? AND index_is_last_comment=1', $indexItemId ) );
try
{
$latest = \IPS\Db::i()->select( 'index_object_id, index_date_updated, index_class', 'core_search_index', array( \IPS\Db::i()->in( 'index_class', $classes ) . ' AND index_item_id=? and index_hidden=0', $indexItemId ), 'index_date_created DESC', array( 0, 1 ) )->first();
\IPS\Db::i()->update( 'core_search_index', array( 'index_is_last_comment' => 1 ), array( 'index_class=? AND index_object_id=?', $latest['index_class'], $latest['index_object_id'] ) );
/* Now reset the item index with the latest comment time */
\IPS\Db::i()->update( 'core_search_index', array( 'index_date_updated' => $latest['index_date_updated'], 'index_date_commented' => $latest['index_date_commented'] ), array( \IPS\Db::i()->in( 'index_class', $classes ) . ' AND index_item_id=? AND index_object_id=?', $indexItemId, $indexItemId ) );
}
catch( \Exception $ex ) { }
catch( \Exception )
{
/* Didn't find a latest? Was it because the entire thing is hidden? */
try
{
$index = \IPS\Db::i()->select( 'index_id, index_item_index_id, index_hidden', 'core_search_index', array( \IPS\Db::i()->in( 'index_class', $classes ) . ' AND index_item_id=?', $indexItemId ), 'index_date_created DESC', array( 0, 1 ) )->first();
/* Is the root item hidden? */
if ( $index['index_id'] == $index['index_item_index_id'] and in_array( $index['index_hidden'], [ -1, 2 ] ) )
{
$latest = \IPS\Db::i()->select( 'index_object_id, index_date_updated, index_class, index_date_commented', 'core_search_index', array( \IPS\Db::i()->in( 'index_class', $classes ) . ' AND index_item_id=? and index_hidden=?', $indexItemId, $index['index_hidden'] ), 'index_date_created DESC', array( 0, 1 ) )->first();
}
else
{
/* No, so we can't find a latest item that is hidden, so skip this */
return FALSE;
}
}
catch( \Exception )
{
return FALSE;
}
}
\IPS\Db::i()->update( 'core_search_index', array( 'index_is_last_comment' => 1 ), array( 'index_class=? AND index_object_id=?', $latest['index_class'], $latest['index_object_id'] ) );
/* Now reset the item index with the latest comment time */
\IPS\Db::i()->update( 'core_search_index', array( 'index_date_updated' => $latest['index_date_updated'], 'index_date_commented' => $latest['index_date_commented'] ), array( \IPS\Db::i()->in( 'index_class', $classes ) . ' AND index_item_id=? AND index_object_id=?', $indexItemId, $indexItemId ) );
}
/**
+6 -3
View File
@@ -922,7 +922,7 @@ class _Query extends \IPS\Content\Search\Query
/* Now, if we have 501 results, then we have to assume there are more, so the join is required */
if ( \count( $tagIds ) == 501 )
{
$tagWhere = array( $itemsOnlyTagSearch . 'index_item_index_id IN ( ? )', \IPS\Db::i()->select( 'index_id', 'core_search_index_tags', array( \IPS\Db::i()->in( 'index_tag', $tags ) ) ) );
$tagWhere = array( $itemsOnlyTagSearch . 'index_item_index_id IN (' . \IPS\Db::i()->select( 'index_id', 'core_search_index_tags', array( \IPS\Db::i()->in( 'index_tag', $tags ) ) ) . ')' );
}
else
{
@@ -1031,7 +1031,7 @@ class _Query extends \IPS\Content\Search\Query
$where[] = $clause;
}
}
/* Return */
return $where;
}
@@ -1101,7 +1101,7 @@ class _Query extends \IPS\Content\Search\Query
search which is actually very efficient as MySQL performs a search on the FT index first to narrow down the results */
if ( static::termIsPhrase( $term ) )
{
$term = str_replace( array( "'", '"' ), '', $term );
$term = str_replace( array( "'", '"', '?' ), '', $term );
foreach ( static::termAsWordsArray( $term ) as $word )
{
@@ -1161,6 +1161,9 @@ class _Query extends \IPS\Content\Search\Query
/* Trailing + or -s are not allowed */
$word = rtrim( $word, '+-' );
/* ? are not allowed either */
$word = str_replace( '?', '', $word );
/* These rules only apply if we're not in a quoted phrase... */
if ( !static::termIsPhrase( $word ) )
+20 -4
View File
@@ -153,11 +153,11 @@ trait Statistics
if ( $name )
{
$subQuery = \IPS\Db::i()->select( 'core_members.member_id', 'core_members', \IPS\Db::i()->like( 'core_members.name', $name ) );
$where[] = [ $commentClass::$databaseTable . '.' . $commentClass::$databaseColumnMap['author'] . ' IN(?)', $subQuery ];
$where[] = [ $commentClass::$databaseTable . '.' . $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['author'] . ' IN(?)', $subQuery ];
}
$members = iterator_to_array( \IPS\Db::i()->select(
$commentClass::$databaseColumnMap['author'],
$commentClass::$databasePrefix . $commentClass::$databaseColumnMap['author'],
$commentClass::$databaseTable,
$where,
NULL,
@@ -232,7 +232,7 @@ trait Statistics
$return = array();
foreach( new \IPS\Patterns\ActiveRecordIterator( \IPS\Db::i()->select( '*', $commentClass::$databaseTable, array( \IPS\Db::i()->in( $commentIdField, $postIds ) ), "FIND_IN_SET( {$commentIdField}, '" . implode( ",", $postIds ) . "' )", array( 0, $count ) ), $commentClass ) as $comment )
{
if ( $comment->canView() and $comment->mapped('item') == $this->$idField )
if ( $comment->canView() and $comment->mapped('item') == $this->$idField and isset( $counts[ $comment->$commentIdField ] ) )
{
$return[] = array( 'comment' => $comment, 'count' => $counts[ $comment->$commentIdField ] );
}
@@ -402,7 +402,18 @@ trait Statistics
protected function _getVisibleWhere()
{
$commentClass = static::$commentClass;
$idField = static::$databaseColumnId;
$lookFor = 'IPS\cms\Records\CommentTopicSync';
if ( mb_substr( $commentClass, 0, mb_strlen( $lookFor ) ) === $lookFor )
{
$idField = 'record_topicid';
}
else
{
$idField = static::$databaseColumnId;
}
$commentItemField = $commentClass::$databasePrefix . $commentClass::$databaseColumnMap['item'];
$where = array();
@@ -417,6 +428,11 @@ trait Statistics
$where[] = array( "{$hiddenColumn} = 0" );
}
if ( $commentClass::commentWhere() !== NULL )
{
$where[] = $commentClass::commentWhere();
}
$where[] = array( $commentItemField . '=?', $this->$idField );
return $where;
+54
View File
@@ -0,0 +1,54 @@
<?php
/**
* @brief Views Trait
* @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 18 Feb 2013
*/
namespace IPS\Content;
/* 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;
}
/**
* Views Trait
*/
trait ViewUpdates
{
/**
* Update View Count
*
* @return void
*/
public function updateViews()
{
$idColumn = static::$databaseColumnId;
$class = \get_called_class();
$countUpdated = false;
if ( \IPS\REDIS_ENABLED and \IPS\CACHE_METHOD == 'Redis' and ( \IPS\CACHE_CONFIG or \IPS\REDIS_CONFIG ) )
{
try
{
\IPS\Redis::i()->zIncrBy( 'topic_views', 1, $class .'__' . $this->$idColumn );
$countUpdated = true;
}
catch( \Exception $e ) {}
}
if ( ! $countUpdated )
{
\IPS\Db::i()->insert( 'core_view_updates', array(
'classname' => $class,
'id' => $this->$idColumn
) );
}
}
}
+1
View File
@@ -22,5 +22,6 @@ if ( !\defined( '\IPS\SUITE_UNIQUE_KEY' ) )
*
* @note Content classes will gain special functionality by implementing this interface
* @note Originally we were just checking for a mapped 'views' column, but decided to use an interface for consistency
* @deprecated Use ViewUpdates trait instead.
*/
interface Views { }
+2 -2
View File
@@ -366,8 +366,8 @@ class _DateTime extends \DateTime
return '%l' . ( $minutes ? ':%M' : '' ) . ( $seconds ? ':%S ' : ' ' ) . ' %p';
}
}
return '%H' . ( $minutes ? ':%M' : '' ) . ( $seconds ? ':%S ' : ' ' );
return '%H' . ( $minutes ? ':%M' : '' ) . ( $seconds ? ':%S ' : '' );
}
/**
+7 -2
View File
@@ -494,13 +494,18 @@ class _Admin extends \IPS\Dispatcher\Standard
{
return $this->acpTabOrder;
}
if ( isset( \IPS\Request::i()->cookie['acpTabs'] ) )
if ( isset( \IPS\Request::i()->cookie['acpTabs'] ) AND !\IPS\Settings::i()->acp_menu_cookie_rebuild )
{
$this->acpTabOrder = json_decode( \IPS\Request::i()->cookie['acpTabs'], TRUE );
}
else
{
if ( \IPS\Settings::i()->acp_menu_cookie_rebuild )
{
\IPS\Settings::i()->changeValues( array( 'acp_menu_cookie_rebuild' => 0 ) );
}
try
{
$this->acpTabOrder = json_decode( \IPS\Db::i()->select( 'data', 'core_acp_tab_order', array( 'id=?', \IPS\Member::loggedIn()->member_id ) )->first(), TRUE );
+3 -1
View File
@@ -271,7 +271,7 @@ class _Api extends \IPS\Dispatcher
/**
* Set Language
*
* @return void
* @return Lang
*/
public function _setLanguage()
{
@@ -285,6 +285,8 @@ class _Api extends \IPS\Dispatcher
{
$this->language = \IPS\Lang::load( \IPS\Lang::defaultLanguage() );
}
return $this->language;
}
catch ( \OutOfRangeException $e )
{
+2 -1
View File
@@ -680,6 +680,7 @@ Disallow: {$path}startTopic/
Disallow: {$path}discover/unread/
Disallow: {$path}markallread/
Disallow: {$path}staff/
Disallow: {$path}cookie/
Disallow: {$path}online/
Disallow: {$path}discover/
Disallow: {$path}leaderboard/
@@ -823,7 +824,7 @@ FILE;
}
/* Don't get in the way of the ModCP, registering, logging in, etc */
$ignoreControllers = [ 'modcp', 'register', 'login', 'redirect' ];
$ignoreControllers = [ 'modcp', 'register', 'login', 'redirect', 'cookie' ];
if( !\IPS\Request::i()->isAjax() and !\in_array( $this->controller, $ignoreControllers ) AND $alert = \IPS\core\Alerts\Alert::getNextAlertForMember( \IPS\Member::loggedIn() ) )
{
$alert->viewed( \IPS\Member::loggedIn() );
+1 -1
View File
@@ -272,7 +272,7 @@ abstract class _Standard extends \IPS\Dispatcher
}
/* VLE */
if ( isset( \IPS\Request::i()->cookie['vle_editor'] ) and \IPS\Request::i()->cookie['vle_editor'] )
if ( \IPS\Lang::vleActive() )
{
\IPS\Output::i()->cssFiles = array_merge( \IPS\Output::i()->cssFiles, \IPS\Theme::i()->css( 'customization/visuallanguage.css', 'core', 'admin' ) );
\IPS\Output::i()->jsFiles = array_merge( \IPS\Output::i()->jsFiles, \IPS\Output::i()->js( 'global_customization.js', 'core', 'global' ) );
+31 -6
View File
@@ -113,6 +113,36 @@ abstract class _Email
return 'IPS\Email\Outgoing\Php';
}
}
/**
* Whether the IPS CIC email system is used. Only affects IPS Cloud Installs
*
* @param string|null $type The type of email
*
* @return bool
*/
public static function usingCicEmail( ?string $type ) : bool
{
return false;
}
/**
* Whether an email is registered as blocked by the current email platform (probably only implemented on cloud)
*
* @param string $email The email
* @param string|null $type The type of email
*
* @return bool
*/
public static function emailIsBlocked( string $email, ?string $type = null ) : bool
{
$type = $type ?: static::TYPE_TRANSACTIONAL;
if ( !is_subclass_of( \get_called_class(), '\IPS\Email' ) )
{
return static::classToUse( $type )::emailIsBlocked( $email, $type );
}
return false;
}
/**
* Factory
@@ -720,12 +750,6 @@ abstract class _Email
*/
public function send( $to, $cc=array(), $bcc=array(), $fromEmail = NULL, $fromName = NULL, $additionalHeaders = array(), $autoSubmitted = TRUE, $updateAds = TRUE )
{
/* Check we have recipients */
if ( !static::_parseRecipients( $to, TRUE ) )
{
return;
}
/* Send the email */
try
{
@@ -1158,6 +1182,7 @@ abstract class _Email
*
* @param string|array|\IPS\Member $data The member or email address, or array of members or email addresses, to send to
* @param bool $emailOnly If TRUE, will use email only rather than names too. Set to TRUE for the "To" header
*
* @return string
* @see <a href='http://www.faqs.org/rfcs/rfc2822.html'>RFC 2822</a>
*/
+1 -1
View File
@@ -243,7 +243,7 @@ class _Email
}
/* We need to convert the text to UTF-8 if it is not already */
if ( mb_strtolower( $charset ) != 'utf-8' )
if ( mb_strtolower( $charset ) != 'utf-8' and ( in_array( mb_strtolower( $charset ), array_map( 'mb_strtolower', mb_list_encodings() ) ) ) )
{
$text = mb_convert_encoding( $text, 'UTF-8', mb_strtoupper( $charset ) );
}
+1 -7
View File
@@ -42,13 +42,7 @@ class _Exception extends \RuntimeException
/* Store these for the extraLogData() method */
$this->extraDetails = $extra;
$this->messageKey = $message;
switch ( $code )
{
default:
$message = \IPS\Member::loggedIn()->language()->addToStack( $message, FALSE, array( 'sprintf' => $extra ) );
break;
}
$message = \IPS\Member::loggedIn()->language()->addToStack( $message, FALSE, array( 'sprintf' => $extra ) );
return parent::__construct( $message, $code, $previous );
}
+3 -4
View File
@@ -546,12 +546,11 @@ class _Amazon extends \IPS\File
*/
public function chunkInit( $filename, $container = '', $obscure = TRUE )
{
$container = trim( $container, '/' );
$container = $container ? trim( $container, '/' ) : $this->container;
$this->setFilename( $filename, $obscure );
$path = $this->container ? "{$this->container}/{$this->filename}" : "{$this->filename}";
$path = $container ? "{$container}/{$this->filename}" : "{$this->filename}";
$r = static::makeRequest( $path, 'POST', $this->configuration, $this->configurationId, NULL, NULL, FALSE, FALSE, array( 'uploads' => '' ) );
$uploadId = (string) $r->decodeXml()->UploadId;
+10
View File
@@ -468,6 +468,16 @@ class _GeoLocation
*/
protected $map;
/**
* Get Requester Location
*
* @return \IPS\GeoLocation
*/
public static function getRequesterLocation(): \IPS\GeoLocation
{
return static::getByIp( \IPS\Request::i()->ipAddress() );
}
/**
* Get by IP address
*
+154 -96
View File
@@ -10,6 +10,8 @@
namespace IPS\Helpers\Chart;
use IPS\Member;
/* To prevent PHP errors (extending class does not exist) revealing path */
if ( !\defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
@@ -107,6 +109,16 @@ abstract class _Dynamic extends \IPS\Helpers\Chart
*/
public $showIntervals = TRUE;
/**
* @brief Allow user to adjust date range
*/
public $showDateRange = TRUE;
/**
* @brief Show save button
*/
public $showSave = TRUE;
/**
* @brief If a warning about timezones needs to be shown
*/
@@ -367,48 +379,80 @@ abstract class _Dynamic extends \IPS\Helpers\Chart
{
try
{
/* If we have filters, we can save them */
if( \count( $this->availableFilters ) > 0 or $this->customFiltersForm )
{
/* Generate a form so we can save our filters as a new saved chart */
$this->form = new \IPS\Helpers\Form;
$this->form->class = 'ipsForm_vertical';
/* Generate a form so we can save our filters as a new saved chart */
$this->form = new \IPS\Helpers\Form;
$this->form->class = 'ipsForm_vertical';
if ( $this->customFiltersForm )
if ( $this->customFiltersForm )
{
if ( $customValues = $this->getCustomFiltersForm()->values( TRUE ) )
{
if ( $customValues = $this->getCustomFiltersForm()->values( TRUE ) )
foreach( $customValues as $key => $value )
{
foreach( $customValues as $key => $value )
{
$this->form->hiddenValues['customform_' . $key ] = $value;
}
$this->form->hiddenValues['customform_' . $key ] = $value;
}
}
}
/* If we have a sub selector from a node form, add on the identifier and pass off to the form to show the ajax */
if ( \IPS\Request::i()->_nodeSelectName )
/* If we have a sub selector from a node form, add on the identifier and pass off to the form to show the ajax */
if ( \IPS\Request::i()->_nodeSelectName )
{
\IPS\Request::i()->_nodeSelectName = $this->identifier . \IPS\Request::i()->_nodeSelectName;
return (string) $this->getCustomFiltersForm();
}
/* We have an existing chart ID */
if( isset( \IPS\Request::i()->chartId ) AND \IPS\Request::i()->chartId != '_default' )
{
$title = '';
foreach( $this->loadAvailableChartTabs() as $chart )
{
\IPS\Request::i()->_nodeSelectName = $this->identifier . \IPS\Request::i()->_nodeSelectName;
return (string) $this->getCustomFiltersForm();
if( $chart['chart_id'] == \IPS\Request::i()->chartId )
{
$title = $chart['chart_title'];
break;
}
}
/* We have an existing chart ID */
if( isset( \IPS\Request::i()->chartId ) AND \IPS\Request::i()->chartId != '_default' )
$custom = array();
$chartFilters = ( isset( \IPS\Request::i()->chartFilters ) and \IPS\Request::i()->chartFilters ) ? \IPS\Request::i()->chartFilters : array();
$this->timescale = $chart['chart_timescale'] ?? 'monthly';
foreach( \IPS\Request::i() as $key => $value )
{
$title = '';
foreach( $this->loadAvailableChartTabs() as $chart )
if ( \mb_substr( $key, 0, 11 ) === 'customform_' )
{
if( $chart['chart_id'] == \IPS\Request::i()->chartId )
{
$title = $chart['chart_title'];
break;
}
$custom[ preg_replace( '#' . $this->identifier . '#', '', $key ) ] = $value;
}
}
$this->form->add( new \IPS\Helpers\Form\Text( 'custom_chart_title', $title, TRUE ) );
if( $values = $this->form->values() )
{
\IPS\Db::i()->update( 'core_saved_charts', array( 'chart_title' => $values['custom_chart_title'] ), array( 'chart_id=? AND chart_member=?', \IPS\Request::i()->chartId, \IPS\Member::loggedIn()->member_id ) );
/* And then return the output we need */
\IPS\Output::i()->json( array(
'title' => $values['custom_chart_title']
) );
}
/* And we want to save our filter updates */
if( isset( \IPS\Request::i()->saveFilters ) )
{
\IPS\Db::i()->update( 'core_saved_charts', array( 'chart_configuration' => json_encode( array_merge( $chartFilters, $custom ) ), 'chart_timescale' => \IPS\Request::i()->timescale ?? $this->timescale ?? 'monthly' ), array( 'chart_id=? AND chart_member=?', \IPS\Request::i()->chartId, \IPS\Member::loggedIn()->member_id ) );
}
}
/* We are not viewing a saved chart */
else
{
$this->form->add( new \IPS\Helpers\Form\Text( 'custom_chart_title', NULL, TRUE ) );
if( $values = $this->form->values() )
{
$custom = array();
$chartFilters = ( isset( \IPS\Request::i()->chartFilters ) and \IPS\Request::i()->chartFilters ) ? \IPS\Request::i()->chartFilters : array();
foreach( \IPS\Request::i() as $key => $value )
{
if ( \mb_substr( $key, 0, 11 ) === 'customform_' )
@@ -417,74 +461,40 @@ abstract class _Dynamic extends \IPS\Helpers\Chart
}
}
$this->form->add( new \IPS\Helpers\Form\Text( 'custom_chart_title', $title, TRUE ) );
$chartFilters = ( isset( \IPS\Request::i()->chartFilters ) and \IPS\Request::i()->chartFilters ) ? \IPS\Request::i()->chartFilters : array();
/* Store the new chart */
$id = \IPS\Db::i()->insert( 'core_saved_charts', array(
'chart_member' => \IPS\Member::loggedIn()->member_id,
'chart_controller' => \IPS\Request::i()->app . '_' . \IPS\Request::i()->module . '_' . \IPS\Request::i()->controller . ( \IPS\Request::i()->tab ? '_' . \IPS\Request::i()->tab : '' ),
'chart_configuration' => json_encode( array_merge( $chartFilters, $custom ) ),
'chart_timescale' => \IPS\Request::i()->timescale ?? $this->timescale ?? 'monthly',
'chart_title' => $values['custom_chart_title'],
) );
if( $values = $this->form->values() )
{
\IPS\Db::i()->update( 'core_saved_charts', array( 'chart_title' => $values['custom_chart_title'] ), array( 'chart_id=? AND chart_member=?', \IPS\Request::i()->chartId, \IPS\Member::loggedIn()->member_id ) );
/* Set some input parameters */
$this->url = $this->url->setQueryString( 'chartId', $id );
\IPS\Request::i()->chartId = $id;
\IPS\Request::i()->filters = array( $this->identifier => \IPS\Request::i()->chartFilters );
/* And then return the output we need */
\IPS\Output::i()->json( array(
'title' => $values['custom_chart_title']
) );
}
/* And we want to save our filter updates */
if( isset( \IPS\Request::i()->saveFilters ) )
{
\IPS\Db::i()->update( 'core_saved_charts', array( 'chart_configuration' => json_encode( array_merge( $chartFilters, $custom ) ) ), array( 'chart_id=? AND chart_member=?', \IPS\Request::i()->chartId, \IPS\Member::loggedIn()->member_id ) );
}
}
/* We are not viewing a saved chart */
else
{
$this->form->add( new \IPS\Helpers\Form\Text( 'custom_chart_title', NULL, TRUE ) );
$this->currentFilters = \IPS\Request::i()->filters;
if( $values = $this->form->values() )
if ( isset( \IPS\Request::i()->filters[ $this->identifier ] ) )
{
$custom = array();
foreach( \IPS\Request::i() as $key => $value )
{
if ( \mb_substr( $key, 0, 11 ) === 'customform_' )
{
$custom[ preg_replace( '#' . $this->identifier . '#', '', $key ) ] = $value;
}
}
$chartFilters = ( isset( \IPS\Request::i()->chartFilters ) and \IPS\Request::i()->chartFilters ) ? \IPS\Request::i()->chartFilters : array();
/* Store the new chart */
$id = \IPS\Db::i()->insert( 'core_saved_charts', array(
'chart_member' => \IPS\Member::loggedIn()->member_id,
'chart_controller' => \IPS\Request::i()->app . '_' . \IPS\Request::i()->module . '_' . \IPS\Request::i()->controller . ( \IPS\Request::i()->tab ? '_' . \IPS\Request::i()->tab : '' ),
'chart_configuration' => json_encode( array_merge( $chartFilters, $custom ) ),
'chart_title' => $values['custom_chart_title'],
) );
/* Set some input parameters */
$this->url = $this->url->setQueryString( 'chartId', $id );
\IPS\Request::i()->chartId = $id;
\IPS\Request::i()->filters = array( $this->identifier => \IPS\Request::i()->chartFilters );
$this->currentFilters = \IPS\Request::i()->filters;
if ( isset( \IPS\Request::i()->filters[ $this->identifier ] ) )
{
$this->url = $this->url->setQueryString( 'filters', array( $this->identifier => $this->currentFilters ) );
}
/* Reset form, since template looks for it and it should not be set for a saved chart */
$this->form = new \IPS\Helpers\Form;
$this->form->class = 'ipsForm_vertical';
$this->form->add( new \IPS\Helpers\Form\Text( 'custom_chart_title', $values['custom_chart_title'], TRUE ) );
/* And then return the output we need */
\IPS\Output::i()->json( array(
'tabHref' => $this->url->stripQueryString( 'filters' ),
'chartId' => $id,
'tabId' => md5( $this->url->acpQueryString() ),
) );
$this->url = $this->url->setQueryString( 'filters', array( $this->identifier => $this->currentFilters ) );
}
/* Reset form, since template looks for it and it should not be set for a saved chart */
$this->form = new \IPS\Helpers\Form;
$this->form->class = 'ipsForm_vertical';
$this->form->add( new \IPS\Helpers\Form\Text( 'custom_chart_title', $values['custom_chart_title'], TRUE ) );
/* And then return the output we need */
\IPS\Output::i()->json( array(
'tabHref' => $this->url->stripQueryString( 'filters' ),
'chartId' => $id,
'tabId' => md5( $this->url->acpQueryString() ),
) );
}
}
@@ -507,8 +517,14 @@ abstract class _Dynamic extends \IPS\Helpers\Chart
else
{
$chartOutput = \IPS\Theme::i()->getTemplate( 'global', 'core', 'global' )->dynamicChart( $this, $output );
/* If we're not showing filter tabs, just return here. */
if ( !$this->showFilterTabs )
{
return $chartOutput;
}
if( ( \count( $this->availableFilters ) > 0 or $this->customFiltersForm ) AND ( !\IPS\Request::i()->isAjax() OR ( \IPS\Request::i()->tab AND !\IPS\Request::i()->chartId ) ) )
if( !\IPS\Request::i()->isAjax() OR ( \IPS\Request::i()->tab AND !\IPS\Request::i()->chartId ) )
{
return \IPS\Theme::i()->getTemplate( 'global', 'core' )->tabs( $this->getChartTabs(), ( isset( \IPS\Request::i()->chartId ) ) ? \IPS\Request::i()->chartId : NULL, $chartOutput, $this->url, 'chartId', 'ipsTabs_small ipsTabs_contained' );
}
@@ -527,11 +543,21 @@ abstract class _Dynamic extends \IPS\Helpers\Chart
\IPS\IPS::exceptionHandler( $e );
}
}
/**
* @brief Show filter tabs
*/
public $showFilterTabs = TRUE;
/**
* @brief Cached tab data
*/
protected $availableChartTabs = NULL;
/**
* @Brief Extension
*/
public $extension = NULL;
/**
* Retrieve tabs based on saved charts
@@ -559,11 +585,31 @@ abstract class _Dynamic extends \IPS\Helpers\Chart
{
if( $this->availableChartTabs === NULL )
{
$this->availableChartTabs = iterator_to_array( \IPS\Db::i()->select( '*', 'core_saved_charts', array( 'chart_member=? AND chart_controller=?', \IPS\Member::loggedIn()->member_id, \IPS\Request::i()->app . '_' . \IPS\Request::i()->module . '_' . \IPS\Request::i()->controller . ( \IPS\Request::i()->tab ? '_' . \IPS\Request::i()->tab : '' ) ) ) );
if ( $this->extension !== NULL )
{
$this->availableChartTabs = iterator_to_array( \IPS\Db::i()->select( '*', 'core_saved_charts', array( 'chart_member=? AND chart_controller=?', \IPS\Member::loggedIn()->member_id, $this->extension->controller ) ) );
}
else
{
$this->availableChartTabs = iterator_to_array( \IPS\Db::i()->select( '*', 'core_saved_charts', array( 'chart_member=? AND chart_controller=?', \IPS\Member::loggedIn()->member_id, \IPS\Request::i()->app . '_' . \IPS\Request::i()->module . '_' . \IPS\Request::i()->controller . ( \IPS\Request::i()->tab ? '_' . \IPS\Request::i()->tab : '' ) ) ) );
}
}
return $this->availableChartTabs;
}
/**
* Set extension
*
* @param \IPS\core\Statistics\Chart $ext Extension
* @return void
*/
public function setExtension( \IPS\core\Statistics\Chart $ext )
{
$this->extension = $ext;
$this->availableChartTabs = NULL;
}
/**
* Flip URL Filter
@@ -685,10 +731,22 @@ abstract class _Dynamic extends \IPS\Helpers\Chart
{
$rawHeaders = $this->headers;
}
/* Ok this is a very horrible hack to get the language hashes converted into the real names and in a format
that we can then reparse later. */
$uglyHack = '';
foreach( $rawHeaders AS $data )
{
$headers[] = $data['label'];
$uglyHack .= str_replace( "\n", " ", $data['label'] ) . "\n";
}
/* This really is awful */
Member::loggedIn()->language()->parseOutputForDisplay( $uglyHack );
foreach( explode( "\n", trim( $uglyHack ) ) as $label )
{
/* Now we have the true string, eg: News, Now! instead of a language hash, eg: 77e90423a7642378a1590420cd66a465 so fputcsv can escape it correctly */
$headers[] = $label;
}
fputcsv( $fh, $headers );
+1 -1
View File
@@ -160,7 +160,7 @@ class _Editor extends FormAbstract
/* Don't minimize if we have a value */
$name = isset( $options['editorId'] ) ? $options['editorId'] : $name;
if ( ( !isset( $options['minimizeWithContent'] ) or !$options['minimizeWithContent'] ) and ( $defaultValue or \IPS\Request::i()->$name or ( isset( \IPS\Request::i()->cookie['vle_editor'] ) and \IPS\Request::i()->cookie['vle_editor'] ) ) )
if ( ( !isset( $options['minimizeWithContent'] ) or !$options['minimizeWithContent'] ) and ( $defaultValue or \IPS\Request::i()->$name or ( \IPS\Lang::vleActive() ) ) )
{
$options['minimize'] = NULL;
}
+7
View File
@@ -33,6 +33,7 @@ class _Email extends Text
public $childDefaultOptions = array(
'accountEmail' => FALSE,
'htmlAutocomplete' => "email",
'bypassProfanity' => \IPS\Helpers\Form\Text::BYPASS_PROFANITY_ALL
);
/**
@@ -65,6 +66,12 @@ class _Email extends Text
/* Check Banned and Allowed Emails only if the data are not coming from an administrator */
if ( !\IPS\Member::loggedIn()->isAdmin() )
{
/* Check it's not known to be undeliverable */
if ( \IPS\Email::emailIsBlocked( $value ) )
{
throw new \DomainException( 'member_email_blocked_info' );
}
/* Check it's not a banned address */
foreach ( \IPS\Db::i()->select( 'ban_content', 'core_banfilters', array( "ban_type=?", 'email' ) ) as $bannedEmail )
{
+15 -10
View File
@@ -51,22 +51,27 @@ class _Member extends Text
public function __construct( $name, $defaultValue=NULL, $required=FALSE, $options=array(), $customValidationCode=NULL, $prefix=NULL, $suffix=NULL, $id=NULL )
{
$this->defaultOptions['autocomplete'] = array(
'source' => 'app=core&module=system&controller=ajax&do=findMember',
'resultItemTemplate' => 'core.autocomplete.memberItem',
'commaTrigger' => false,
'unique' => true,
'minAjaxLength' => 3,
'disallowedCharacters' => array(),
'lang' => 'mem_optional',
'source' => 'app=core&module=system&controller=ajax&do=findMember',
'resultItemTemplate' => 'core.autocomplete.memberItem',
'commaTrigger' => false,
'unique' => true,
'minAjaxLength' => 3,
'disallowedCharacters' => array(),
'lang' => 'mem_optional',
);
if( \count( $options ) and array_key_exists( 'multiple', $options ) and $options['multiple'] > 0 )
if (\count($options) and array_key_exists('multiple', $options) and $options['multiple'] > 0)
{
$this->defaultOptions['autocomplete']['maxItems'] = $options['multiple'];
}
elseif ( !array_key_exists( 'multiple', $options ) )
} elseif (!array_key_exists('multiple', $options))
{
$this->defaultOptions['autocomplete']['maxItems'] = $this->childDefaultOptions['multiple'];
}
/* Explicitly merge autocomplete options */
if( array_key_exists('autocomplete', $options ) )
{
$options['autocomplete'] = array_merge( $options['autocomplete'], $this->defaultOptions['autocomplete'] );
}
parent::__construct( $name, $defaultValue, $required, $options, $customValidationCode, $prefix, $suffix, $id );
}
+7 -1
View File
@@ -43,7 +43,8 @@ class _Text extends TextArea
'resultItemTemplate'=> 'core.foo.bar', // Can be used to specify a custom JavaScript template to use for the result
'minAjaxLength' => 3, // Minimum length of value before sending AJAX lookup call
'disallowedCharacters' => array(), // An array of disallowed characters (default < > ' ")
'minimized' => TRUE // Whether the autocomplete shows a 'choose' link to activate. Existing values or required = true will always override this.
'minimized' => TRUE // Whether the autocomplete shows a 'choose' link to activate. Existing values or required = true will always override this.
'alphabetical' => FALSE, // Force values to be sorted alphabetically.
),
'placeholder' => 'e.g. ...', // A placeholder (NB: Will only work on compatible browsers)
'regex' => '/[A-Z]+/i', // RegEx of acceptable value
@@ -202,6 +203,11 @@ class _Text extends TextArea
{
$return = array_filter( array_map( 'trim', explode( "\n", $return ) ) );
}
if ( \is_array( $return ) AND isset( $this->options['autocomplete']['alphabetical'] ) AND $this->options['autocomplete']['alphabetical'] )
{
natcasesort( $return );
}
}
/* Remove all invisible characters if this is a username */
+14 -9
View File
@@ -244,7 +244,8 @@ class _Upload extends FormAbstract
/* If there is an error, an exception will be thrown and will be caught below like normal */
\IPS\File::validateUpload( $fileArray, $this->options['allowedFileTypes'], $this->options['maxFileSize'] );
$exif = NULL;
if ( $fileObj->isImage() )
{
try
@@ -254,6 +255,7 @@ class _Upload extends FormAbstract
if( $image::exifSupported() )
{
$image->setExifData( $fileObj->contents() );
$exif = $image->parseExif();
}
$hasBeenResized = $image->resizeToMax( $this->options['image']['maxWidth'] ?? NULL, $this->options['image']['maxHeight'] ?? NULL );
@@ -266,16 +268,17 @@ class _Upload extends FormAbstract
}
catch ( \Exception $e ) { }
}
$insertId = \IPS\Db::i()->insert( 'core_files_temp', array(
'upload_key' => md5( $this->name . session_id() ),
'filename' => $fileObj->originalFilename,
'mime' => \IPS\File::getMimeType( $fileObj->originalFilename ),
'contents' => (string) $fileObj,
'time' => time(),
'storage_extension' => $this->options['storageExtension']
'storage_extension' => $this->options['storageExtension'],
'exif' => $exif ? json_encode( $exif ) : NULL
) );
if ( $this->options['callback'] )
{
$callbackFunction = $this->options['callback'];
@@ -466,7 +469,7 @@ class _Upload extends FormAbstract
/* The html() method is called more than once, however $this->value is wiped out so if it was an attachments array all of the array properties are lost */
$templateFunction = $this->template;
$this->builtHtml = $templateFunction( $this->name, $this->value, $this->options['minimize'], $maxFileSize, $this->options['maxFiles'], $this->maxChunkSize, $this->options['totalMaxSize'], $this->options['allowedFileTypes'], $uploadKey, $this->options['multiple'], $this->options['postKey'], $this->options['temporary'] or ( isset( \IPS\Request::i()->cookie['vle_editor'] ) and \IPS\Request::i()->cookie['vle_editor'] ), $this->options['template'], $existing, $this->options['default'], $this->options['supportsDelete'], $maxImageDims, $this->options['allowStockPhotos'] );
$this->builtHtml = $templateFunction( $this->name, $this->value, $this->options['minimize'], $maxFileSize, $this->options['maxFiles'], $this->maxChunkSize, $this->options['totalMaxSize'], $this->options['allowedFileTypes'], $uploadKey, $this->options['multiple'], $this->options['postKey'], $this->options['temporary'] or ( \IPS\Lang::vleActive() ), $this->options['template'], $existing, $this->options['default'], $this->options['supportsDelete'], $maxImageDims, $this->options['allowStockPhotos'] );
return $this->builtHtml;
}
@@ -721,8 +724,9 @@ class _Upload extends FormAbstract
{
$options = $this->options;
$canBeModerated = $options['canBeModerated'];
$exifData = array();
$fileObjects = \IPS\File::createFromUploads( $this->options['storageExtension'], $fieldName, $this->options['allowedFileTypes'], $this->options['maxFileSize'], $this->options['totalMaxSize'], 0, function( $contents, $filename, $i ) use ( $options, &$requiresModeration, $canBeModerated )
$fileObjects = \IPS\File::createFromUploads( $this->options['storageExtension'], $fieldName, $this->options['allowedFileTypes'], $this->options['maxFileSize'], $this->options['totalMaxSize'], 0, function( $contents, $filename, $i ) use ( $options, &$requiresModeration, $canBeModerated, &$exifData )
{
$ext = mb_strtolower( mb_substr( $filename, mb_strrpos( $filename, '.' ) + 1 ) );
@@ -740,6 +744,7 @@ class _Upload extends FormAbstract
/* If type JPG, image handler may strip meta data on image output */
if( $hasBeenResized OR $image->hasBeenRotated OR $image->type == 'jpeg' )
{
$exifData[ $i ] = $image->parseExif();
$contents = (string) $image;
}
}
@@ -748,14 +753,14 @@ class _Upload extends FormAbstract
return $contents;
}, $this->options['storageContainer'], $this->options['obscure'] );
foreach ( $fileObjects as $i => $fileObj )
{
$exif = NULL;
if( \IPS\Image::exifSupported() and $fileObj->isImage() and $fileObj->exifData )
if( isset( $exifData[ $i ] ) AND $fileExifData = $exifData[ $i ] OR ( \IPS\Image::exifSupported() and $fileObj->isImage() and $fileExifData = $fileObj->exifData ) )
{
$exif = json_encode( $fileObj->exifData );
$exif = json_encode( $fileExifData );
}
$data = $this->populateTempData( array(
+3 -3
View File
@@ -452,8 +452,8 @@ class _Content extends Table
{
$itemIds[] = $item->$idField;
}
foreach( \IPS\Db::i()->select( '*', 'core_reputation_index', array( 'rep_class=? AND ' . \IPS\Db::i()->in( 'item_id', $itemIds ), $class::$commentClass ) ) as $react )
foreach( \IPS\Db::i()->select( '*', 'core_reputation_index', array( 'rep_class=? AND ' . \IPS\Db::i()->in( 'item_id', $itemIds ), $class::$commentClass ), NULL, 1000 ) as $react )
{
$reactions[ $react['item_id'] ][] = $react;
}
@@ -1078,7 +1078,7 @@ class _Content extends Table
$class = $this->class;
/* ID and Title columns should be ascending */
if( $column != $this->defaultSortBy AND ( $column == $class::$databaseColumnId OR $column == $class::$databaseColumnMap[ 'title' ] ) )
if( isset( $class::$databaseColumnMap[ 'title' ] ) AND $column != $this->defaultSortBy AND ( $column == $class::$databaseColumnId OR $column == $class::$databaseColumnMap[ 'title' ] ) )
{
return 'asc';
}
+4 -2
View File
@@ -316,7 +316,7 @@ class _Db extends Table
}
/* Count results (for pagination) */
$count = $this->db->select( 'count(*)', $this->table, $where );
$count = $this->db->select( 'count(*)', $this->table, $where, NULL, NULL, $this->groupBy );
if ( \count( $this->joins ) )
{
foreach( $this->joins as $join )
@@ -324,7 +324,9 @@ class _Db extends Table
$count->join( $join['from'], ( isset( $join['where'] ) ? $join['where'] : null ), ( isset( $join['type'] ) ) ? $join['type'] : 'LEFT' );
}
}
$count = $count->first();
$count = $this->groupBy ? $count->count() : $count->first();
$selectPrefix = ( $this->groupBy ) ? '' : $this->table . '.*, ';
/* Now get column headers */
+75 -1
View File
@@ -160,6 +160,10 @@ class _Curl
public function __destruct()
{
curl_close( $this->curl );
if( static::$_multiHandle instanceof \CurlMultiHandle )
{
curl_multi_close( static::$_multiHandle );
}
}
/**
@@ -378,6 +382,7 @@ class _Curl
/**
* Execute the request
*
* @todo Remove if multi handle works out long term.
* @return \IPS\Http\Response
* @throws \IPS\Http\Request\CurlException
*/
@@ -407,6 +412,75 @@ class _Curl
/* Return it */
return new \IPS\Http\Response( $output );
}
/**
* @breif Store the cURL Multi Handle
*/
protected static $_multiHandle;
/**
* Execute the request via cURL Multi - We use this to cache and share connections between requests
*
* @return \IPS\Http\Response
* @throws \IPS\Http\Request\CurlException
*/
protected function _executeMh(): \IPS\Http\Response
{
/* Init multi handle if needed */
if( empty( static::$_multiHandle ) )
{
static::$_multiHandle = curl_multi_init();
}
/* Store handle for this request */
curl_multi_add_handle( static::$_multiHandle, $this->curl );
/* Execute request and wait for response */
$active = NULL;
do
{
$ret = curl_multi_exec( static::$_multiHandle, $active );
}
while( $ret == CURLM_CALL_MULTI_PERFORM );
while( $active && $ret == CURLM_OK )
{
if( curl_multi_select( static::$_multiHandle ) != -1 )
{
do
{
$mrc = curl_multi_exec( static::$_multiHandle, $active );
}
while( $mrc == CURLM_CALL_MULTI_PERFORM );
}
}
$output = curl_multi_getcontent( $this->curl );
/* Remove handles we no longer need */
curl_multi_remove_handle( static::$_multiHandle, $this->curl );
/* Log - but because the output can be large, only do this if we explicitly have debug logging enabled */
if ( \defined('\IPS\DEBUG_LOG') and \IPS\DEBUG_LOG )
{
\IPS\Log::debug( "\n\n------------------------------------\ncURL REQUEST: {$this->url}\n------------------------------------\n\n" . implode( "\n", $this->headersForLog ) . "\n\n" . var_export( $this->dataForLog, TRUE ) . "\n\n------------------------------------\nRESPONSE\n------------------------------------\n\n" . $output, 'request' );
}
/* Errors? */
if ( $output === FALSE )
{
throw new CurlException( $this->url . "\n" . curl_error( $this->curl ), curl_errno( $this->curl ) );
}
/* If this is FTP we need to fudge the headers a little */
if( isset( $this->url->data['scheme'] ) and $this->url->data['scheme'] == 'ftp' )
{
$output = "HTTP/1.1 200 OK\nFTP: True\r\n\r\n" . $output;
}
/* Return it */
return new \IPS\Http\Response( $output );
}
/**
* Execute the request and follow redirects id necessary
@@ -419,7 +493,7 @@ class _Curl
protected function _executeAndFollowRedirects( $method, $params=NULL )
{
/* Execute */
$response = $this->_execute();
$response = $this->_executeMh();
/* Either return it or follow it */
if ( $this->followRedirects and \in_array( $response->httpResponseCode, array( 301, 302, 303, 307, 308 ) ) )
+1 -1
View File
@@ -638,7 +638,7 @@ class _Url
*/
public function __toString()
{
return (string) $this->url;
return (string) $this->url;
}
/* !Encoding and Decoding */
+1 -18
View File
@@ -91,26 +91,9 @@ class _Useragent
*/
public function parseUserAgent()
{
/* Is it ours? */
if ( preg_match( '/^InvisionCommunityApp\/(\d+\.\d+\.\d+) \((.+?)\) (.+?) \((.+?)\)$/', $this->useragent, $matches ) ) // Matches: 1 = App version, 2 = App name (differentiates multicommunity vs white-label) 3 = "Android" or "iOS/X.Y.Z" 4 = Semi-human-friendly name (e.g. "iPhone11,6" is iPhone XS Max)
{
if ( $matches[3] === 'Android' )
{
$this->platform = $matches[4];
}
else
{
$this->platform = preg_replace( '/^(.+?)\d+,\d+$/', '$1', $matches[4] );
}
$this->browser = 'InvisionCommunityApp';
$this->browserVersion = $matches[1];
return;
}
/* Set basic data */
require_once( \IPS\ROOT_PATH . '/system/3rd_party/PhpUserAgent/UserAgentParser.php' );
$data = parse_user_agent( $this->useragent );
$data = \donatj\UserAgent\parse_user_agent( $this->useragent );
$this->platform = $data['platform'];
$this->browser = $data['browser'];
$this->browserVersion = $data['version'];
+63 -7
View File
@@ -1439,7 +1439,7 @@ class _Lang extends \IPS\Node\Model
$buttons = array_merge( $buttons, parent::getButtons( $url, $subnode ) );
if ( \IPS\Member::loggedIn()->hasAcpRestriction( 'core', 'languages', 'lang_words' ) AND !$this->marketplace_id )
if ( \IPS\Member::loggedIn()->hasAcpRestriction( 'core', 'languages', 'lang_words' ) )
{
$buttons['upload'] = array(
'icon' => 'upload',
@@ -1751,9 +1751,47 @@ class _Lang extends \IPS\Node\Model
*/
public function stripVLETags( $output )
{
/* We accidentally called stripVLE for each response, even if VLE was disabled, which had a side effect of casting everything to a string which we really want and need */
if ( ! static::vleActive() )
{
if( !\is_array( $output ) )
{
return (string) $output;
}
$replacement = array();
foreach ( $output as $key => $value )
{
$replacement[ $key ] = $this->stripVLETags( $value );
}
return $replacement;
}
if( !\is_array( $output ) )
{
return preg_replace( "/#VLE#.+?#\!#\[(.+?)\]#\!##/", "$1", $output );
preg_match_all( '/#VLE#(.+?)#!#/', (string) $output, $matches, PREG_SET_ORDER );
foreach( $matches as $match )
{
$replace = $match[1]; /* Possibly better than nothing */
if ( isset( $this->words[$match[1]] ) )
{
$replace = $this->words[$match[1]];
}
else
{
try
{
$replace = $this->get( $match[1] );
}
catch( \OutOfRangeException $e ) {}
}
$output = str_replace( $match[0], $replace, $output );
}
return $output;
}
$replacement = array();
@@ -2033,12 +2071,9 @@ class _Lang extends \IPS\Node\Model
}
/* Add VLE tags */
if ( $values[ 'vle' ] and $replacement and isset( \IPS\Request::i(
)->cookie[ 'vle_editor' ] ) and \IPS\Request::i()->cookie[ 'vle_editor' ] and \IPS\Member::loggedIn(
)->hasAcpRestriction( 'core', 'languages', 'lang_words' )
)
if ( $values[ 'vle' ] and $replacement and static::vleActive() )
{
$replacement = "#VLE#{$values['key']}#!#[{$replacement}]#!##";
$replacement = "#VLE#{$values['key']}#!#";
}
if ( isset( $values[ 'options' ][ 'returnInto' ] ) )
@@ -2106,4 +2141,25 @@ class _Lang extends \IPS\Node\Model
$form->add( new \IPS\Helpers\Form\Translatable( 'word_custom', NULL, FALSE, array( 'textArea' => TRUE ) ) );
}
}
/**
* Is the visual language editor active?
*
* @return bool
*/
public static function vleActive(): bool
{
return isset( \IPS\Request::i()->cookie[ 'vle_editor' ] ) and \IPS\Request::i()->cookie[ 'vle_editor' ] and \IPS\Member::loggedIn()->hasAcpRestriction( 'core', 'languages', 'lang_words' );
}
/**
* Return an array of keys > values for the VLE
*
* @return array
*/
public function vleForJson(): array
{
/* if we're using IN_DEV, this is all of the strings, so... good luck I guess */
return $this->words;
}
}
+42
View File
@@ -550,4 +550,46 @@ class _Twitter extends \IPS\Login\Handler\OAuth1
return $this->_sendRequest( 'post', \IPS\Http\Url::external('https://upload.twitter.com/1.1/media/upload.json'), array(), $accessToken, $accessTokenSecret, array(), array( $mimeBoundary, $data ) )->decodeJson();
}
/**
* [Node] Set whether this node is enabled
*
* @param bool|int $enabled Whether to set it enabled or disabled
* @return void
*/
protected function set__enabled( $enabled )
{
parent::set__enabled( $enabled );
$this->_checkSocialPromotes();
}
/**
* [ActiveRecord] Delete Record
*
* @return void
*/
public function delete()
{
parent::delete();
$this->_checkSocialPromotes();
}
/**
* Disable social promotes Twitter method if all Twitter login handlers are disabled
*
* @return void
*/
protected function _checkSocialPromotes()
{
try
{
\IPS\Db::i()->select( '*', 'core_login_methods', array( 'login_classname=? AND login_id!=?', 'IPS\Login\Handler\OAuth1\Twitter', $this->_id ) )->first();
}
catch( \UnderflowException $e )
{
$promoter = \IPS\core\Promote::getPromoter('Twitter');
$promoter->enabled = FALSE;
$promoter->save();
}
}
}
+1 -1
View File
@@ -405,7 +405,7 @@ class _Login
/* Can we get a physical location */
try
{
$location = \IPS\GeoLocation::getByIp( \IPS\Request::i()->ipAddress() );
$location = \IPS\GeoLocation::getRequesterLocation();
}
catch ( \Exception $e )
{
+11
View File
@@ -551,6 +551,12 @@ class _Handler extends \IPS\MFA\MFAHandler
*/
public function toggle( $enabled )
{
/* This handler is deprecated, so if it's already disabled, don't allow it to be re-enabled */
if( !$this->isEnabled() )
{
return FALSE;
}
if ( $enabled )
{
static::verifyApiKey( \IPS\Settings::i()->authy_key );
@@ -566,6 +572,11 @@ class _Handler extends \IPS\MFA\MFAHandler
*/
public function acpSettings()
{
if( !$this->isEnabled() )
{
\IPS\Output::i()->error( 'authy_deprecated_message', '2C345/3' );
}
$form = new \IPS\Helpers\Form;
$form->add( new \IPS\Helpers\Form\Text( 'authy_key', \IPS\Settings::i()->authy_key, TRUE, array(), function( $val ) {
+2 -1
View File
@@ -49,7 +49,8 @@ abstract class _MFAHandler
return array(
'authy' => new \IPS\MFA\Authy\Handler(),
'google' => new \IPS\MFA\GoogleAuthenticator\Handler(),
'questions' => new \IPS\MFA\SecurityQuestions\Handler()
'questions' => new \IPS\MFA\SecurityQuestions\Handler(),
'verify' => new \IPS\MFA\Verify\Handler()
);
}
+37
View File
@@ -0,0 +1,37 @@
<?php
/**
* @brief Verify Exception
* @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
* @subpackage
* @since 4/4/2023
*/
namespace IPS\MFA\Verify;
/* 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;
}
class _Exception extends \DomainException
{
const EXPIRED_CODE = 20404;
public function __construct( string $message = "", int $code = 0, ?\Throwable $previous = null )
{
switch( $code )
{
case static::EXPIRED_CODE:
$message = 'verify_mfa_reused_code';
break;
}
parent::__construct( $message, $code, $previous );
}
}
+545
View File
@@ -0,0 +1,545 @@
<?php
/**
* @brief Multi Factor Authentication Handler for Verify
* @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
* @subpackage
* @since 3/29/2023
*/
namespace IPS\MFA\Verify;
/* To prevent PHP errors (extending class does not exist) revealing path */
use IPS\MFA\verify\Exception;
if( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class _Handler extends \IPS\MFA\MFAHandler
{
/**
* @brief Key
*/
protected $key = 'verify';
/**
* Handler is enabled
*
* @return bool
*/
public function isEnabled()
{
return \IPS\Settings::i()->verify_enabled;
}
/**
* Member *can* use this handler (even if they have not yet configured it)
*
* @param \IPS\Member $member The member
* @return bool
*/
public function memberCanUseHandler( \IPS\Member $member )
{
return \IPS\Settings::i()->verify_groups == '*' or $member->inGroup( explode( ',', \IPS\Settings::i()->verify_groups ) );
}
/**
* Member has configured this handler
*
* @param \IPS\Member $member The member
* @return bool
*/
public function memberHasConfiguredHandler( \IPS\Member $member )
{
return isset( $member->mfa_details['verify'] ) and $member->mfa_details['verify']['setup'];
}
/**
* Show a setup screen
*
* @param \IPS\Member $member The member
* @param bool $showingMultipleHandlers Set to TRUE if multiple options are being displayed
* @param \IPS\Http\Url $url URL for page
* @return string
*/
public function configurationScreen( \IPS\Member $member, $showingMultipleHandlers, \IPS\Http\Url $url )
{
$mfaDetails = $member->mfa_details;
/* Starting again? */
if ( isset( $mfaDetails['verify']['pendingId'] ) and isset( \IPS\Request::i()->_new ) )
{
unset( $mfaDetails['verify']['pendingId'] );
$member->mfa_details = $mfaDetails;
$member->save();
}
/* If we have already entered our phone number, ask for the code */
if ( isset( $mfaDetails['verify'] ) and isset( $mfaDetails['verify']['pendingId'] ) and !isset( $_SESSION['verifyConfigureError'] ) )
{
/* Asking for a text or call instead? */
$availableMethods = explode( ',', \IPS\Settings::i()->verify_setup );
if ( isset( \IPS\Request::i()->verify_method ) and $mfaDetails['verify']['setupMethod'] == 'verify' and \in_array( \IPS\Request::i()->verify_method, $availableMethods ) )
{
try
{
/* Verify with the API */
$response = static::_api( "Services/" . \IPS\Settings::i()->verify_service_sid . "/VerificationCheck", 'post', array(
'VerificationSid' => $mfaDetails['verify']['pendingId'],
'Code' => \IPS\Request::i()->verify_auth_code
) );
if( $response['status'] == 'approved' )
{
/* Update details */
$mfaDetails['verify']['setupMethod'] = \IPS\Request::i()->verify_method;
$member->mfa_details = $mfaDetails;
$member->save();
}
else
{
throw new \IPS\MFA\Verify\Exception( 'verify_mfa_invalid_code' );
}
}
catch ( \Exception $e )
{
\IPS\Log::log( $e, 'verify' );
$_SESSION['verifyAuthError'] = $e->getMessage();
}
}
/* Display */
return \IPS\Theme::i()->getTemplate( 'login', 'core', 'global' )->verifyAuthenticate( $mfaDetails['verify']['setupMethod'], TRUE, $_SESSION['verifyAuthError'] ?? 'verify_error', TRUE, explode( ',', \IPS\Settings::i()->verify_setup ), $url );
}
else
{
/* If they have used their allowed attempts, make them wait */
if ( isset( $mfaDetails['verify'] ) and isset( $mfaDetails['verify']['changeAttempts'] ) and $mfaDetails['verify']['changeAttempts'] >= \IPS\Settings::i()->verify_setup_tries )
{
$lockEndTime = $mfaDetails['verify']['lastChangeAttempt'] + ( \IPS\Settings::i()->verify_setup_lockout * 3600 );
if ( $lockEndTime < time() )
{
$mfaDetails['verify']['changeAttempts'] = 0;
$member->mfa_details = $mfaDetails;
$member->save();
}
else
{
return \IPS\Theme::i()->getTemplate( 'login', 'core', 'global' )->verifySetupLockout( $showingMultipleHandlers, \IPS\DateTime::ts( $lockEndTime ) );
}
}
/* Otherwise show the form */
return \IPS\Theme::i()->getTemplate( 'login', 'core', 'global' )->verifySetup( \IPS\Request::i()->countryCode ?? \IPS\Helpers\Form\Address::calculateDefaultCountry(), \IPS\Request::i()->phoneNumber ?? '', $showingMultipleHandlers, explode( ',', \IPS\Settings::i()->verify_setup ), $_SESSION['verifyConfigureError'] ?? NULL );
}
}
/**
* Submit configuration screen. Return TRUE if was accepted
*
* @param \IPS\Member $member The member
* @return bool
*/
public function configurationScreenSubmit( \IPS\Member $member )
{
$mfaDetails = $member->mfa_details;
/* If we've enterred a code, verify it */
if ( isset( $mfaDetails['verify'] ) and isset( $mfaDetails['verify']['pendingId'] ) and isset( \IPS\Request::i()->verify_auth_code ) )
{
$_SESSION['verifyAuthError'] = NULL;
try
{
/* Verify with the API */
$response = static::_api( "Services/" . \IPS\Settings::i()->verify_service_sid . "/VerificationCheck", 'post', array(
'VerificationSid' => $mfaDetails['verify']['pendingId'],
'Code' => \IPS\Request::i()->verify_auth_code
) );
if( $response['status'] == 'approved' )
{
$mfaDetails['verify'] = array( 'id' => $response['to'], 'setup' => true );
$member->mfa_details = $mfaDetails;
$member->save();
$member->logHistory( 'core', 'mfa', array( 'handler' => $this->key, 'enable' => TRUE ) );
return true;
}
else
{
throw new \IPS\MFA\Verify\Exception( 'verify_mfa_invalid_code' );
}
}
catch ( \Exception $e )
{
\IPS\Log::log( $e, 'verify' );
$_SESSION['verifyAuthError'] = $e->getMessage();
return false;
}
}
/* Otherwise we need to generate an ID */
elseif ( \IPS\Request::i()->phoneNumber )
{
/* Do we need to wait a while? */
if ( isset( $mfaDetails['verify'] ) and isset( $mfaDetails['verify']['changeAttempts'] ) and $mfaDetails['verify']['changeAttempts'] >= \IPS\Settings::i()->verify_setup_tries )
{
return false;
}
/* Reformat the phone number to E.164 */
$countryCode = \substr( \IPS\Request::i()->countryCode, \strpos( \IPS\Request::i()->countryCode, "-" ) + 1 );
$phoneNumber = "+" . $countryCode . \IPS\Request::i()->phoneNumber;
/* Call verify */
$availableMethods = explode( ',', \IPS\Settings::i()->verify_setup );
$method = ( isset( \IPS\Request::i()->method ) and \in_array( \IPS\Request::i()->method, $availableMethods ) ) ? \IPS\Request::i()->method : array_shift( $availableMethods );
$_SESSION['verifyConfigureError'] = NULL;
$channel = ( \IPS\Request::i()->method == 'phone' ) ? 'call' : \IPS\Request::i()->method;
try
{
$response = static::_api( "Services/" . \IPS\Settings::i()->verify_service_sid . "/Verifications", 'post', array(
'To' => $phoneNumber,
'Channel' => $channel
) );
}
catch ( \Exception $e )
{
\IPS\Log::log( $e, 'verify' );
$_SESSION['verifyConfigureError'] = $e->getMessage();
return false;
}
/* Log the details */
$mfaDetails['verify']['pendingId'] = $response['sid'];
if ( !isset( $mfaDetails['verify']['changeAttempts'] ) )
{
$mfaDetails['verify']['changeAttempts'] = 1;
}
else
{
$mfaDetails['verify']['changeAttempts']++;
}
$mfaDetails['verify']['lastChangeAttempt'] = time();
if ( !isset( $mfaDetails['verify']['setup'] ) )
{
$mfaDetails['verify']['setup'] = false;
}
$mfaDetails['verify']['setupMethod'] = $method;
$member->mfa_details = $mfaDetails;
$member->save();
return false;
}
return false;
}
/**
* Get the form for a member to authenticate
*
* @param \IPS\Member $member The member
* @param \IPS\Http\Url $url URL for page
* @return string
*/
public function authenticationScreen( \IPS\Member $member, \IPS\Http\Url $url )
{
$mfaDetails = $member->mfa_details;
$availableMethods = explode( ',', \IPS\Settings::i()->verify_method );
/* If we sent a code, but it was less than one minute ago, log a failure and reset */
if ( isset( $mfaDetails['verify']['sent'] ) and $mfaDetails['verify']['sent']['time'] < ( time() - 60 ) and !isset( \IPS\Request::i()->verify_auth_code ) )
{
unset( $mfaDetails['verify']['sent'] );
$member->mfa_details = $mfaDetails;
$member->failed_mfa_attempts++;
$member->save();
}
/* If text message is the only available option, or we have chosen that option, do that... */
$selectedMethod = \IPS\Request::i()->verify_method ?? NULL;
if( $selectedMethod === NULL )
{
if( isset( \IPS\Request::i()->verify_auth_code ) and isset( $mfaDetails['verify']['sent'] ) )
{
$selectedMethod = $mfaDetails['verify']['sent']['method'];
}
elseif( \count( $availableMethods ) == 1 )
{
$selectedMethod = $availableMethods[0];
}
}
/* Send the text if we haven't already */
if ( !isset( $mfaDetails['verify']['sent'] ) )
{
try
{
$channel = $selectedMethod == 'phone' ? 'call' : $selectedMethod;
$response = static::_api( "Services/" . \IPS\Settings::i()->verify_service_sid . "/Verifications", 'post', array(
'To' => $mfaDetails['verify']['id'],
'Channel' => $channel
) );
/* Update details */
$mfaDetails['verify']['sent'] = array( 'method' => $selectedMethod, 'time' => time() );
$member->mfa_details = $mfaDetails;
$member->save();
}
catch ( \Exception $e )
{
\IPS\Log::log( $e, 'verify' );
return \IPS\Theme::i()->getTemplate( 'login', 'core', 'global' )->verifyError( $e->getMessage() );
}
}
/* Show screen */
return \IPS\Theme::i()->getTemplate( 'login', 'core', 'global' )->verifyAuthenticate( $selectedMethod ?: 'choose', ( isset( $mfaDetails['verify']['sent'] ) and $mfaDetails['verify']['sent']['method'] == $selectedMethod ), $_SESSION['verifyAuthError'] ?? 'verify_error', $mfaDetails['verify']['setup'] ?? FALSE, $availableMethods, $url );
}
/**
* Submit authentication screen. Return TRUE if was accepted
*
* @param \IPS\Member $member The member
* @return string
*/
public function authenticationScreenSubmit( \IPS\Member $member )
{
$mfaDetails = $member->mfa_details;
$_SESSION['verifyAuthError'] = NULL;
try
{
if ( isset( \IPS\Request::i()->verify_auth_code ) )
{
/* Verify with the API */
$response = static::_api( "Services/" . \IPS\Settings::i()->verify_service_sid . "/VerificationCheck", 'post', array(
'To' => $mfaDetails['verify']['id'],
'Code' => \IPS\Request::i()->verify_auth_code
) );
if( $response['status'] == 'approved' )
{
$member->mfa_details = $mfaDetails;
$member->save();
return true;
}
else
{
throw new \IPS\MFA\Verify\Exception( 'verify_mfa_invalid_code' );
}
}
else
{
return false;
}
}
catch ( \Exception $e )
{
\IPS\Log::log( $e, 'verify' );
$_SESSION['verifyAuthError'] = $e->getMessage();
return false;
}
}
/**
* Toggle
*
* @param bool $enabled On/Off
* @return bool
*/
public function toggle( $enabled )
{
if ( $enabled )
{
static::verifyApiKeys( \IPS\Settings::i()->verify_sid, \IPS\Settings::i()->verify_token );
}
\IPS\Settings::i()->changeValues( array( 'verify_enabled' => $enabled ) );
}
/**
* ACP Settings
*
* @return string
*/
public function acpSettings()
{
$form = new \IPS\Helpers\Form;
$form->add( new \IPS\Helpers\Form\Text( 'verify_sid', \IPS\Settings::i()->verify_sid, TRUE, array(), NULL, NULL, \IPS\Member::loggedIn()->language()->addToStack( 'verify_sid_suffix' ) ) );
$form->add( new \IPS\Helpers\Form\Text( 'verify_token', \IPS\Settings::i()->verify_token, TRUE ) );
$form->add( new \IPS\Helpers\Form\CheckboxSet( 'verify_groups', \IPS\Settings::i()->verify_groups == '*' ? '*' : explode( ',', \IPS\Settings::i()->verify_groups ), FALSE, array(
'multiple' => TRUE,
'options' => array_combine( array_keys( \IPS\Member\Group::groups() ), array_map( function( $_group ) { return (string) $_group; }, \IPS\Member\Group::groups() ) ),
'unlimited' => '*',
'unlimitedLang' => 'everyone',
'impliedUnlimited' => TRUE
) ) );
$form->addHeader('verify_setup_header');
$form->add( new \IPS\Helpers\Form\CheckboxSet( 'verify_setup', explode( ',', \IPS\Settings::i()->verify_setup ), TRUE, array( 'options' => array(
'sms' => 'verify_method_sms',
'phone' => 'verify_method_phone',
'whatsapp' => 'verify_method_whatsapp'
) ) ) );
$form->add( new \IPS\Helpers\Form\Custom( 'verify_setup_protection', array( \IPS\Settings::i()->verify_setup_tries, \IPS\Settings::i()->verify_setup_lockout ), FALSE, array(
'getHtml' => function( $field ) {
return \IPS\Theme::i()->getTemplate('settings')->verifySetupProtection( $field->value );
}
) ) );
$form->addHeader('verify_authenticate_header');
$form->add( new \IPS\Helpers\Form\CheckboxSet( 'verify_method', explode( ',', \IPS\Settings::i()->verify_method ), TRUE, array(
'options' => array(
'sms' => 'verify_method_sms',
'phone' => 'verify_method_phone',
'whatsapp' => 'verify_method_whatsapp'
)
) ) );
if ( $values = $form->values() )
{
try
{
$data = static::verifyApiKeys( $values['verify_sid'], $values['verify_token'] );
/* If we do not have any services yet, create one */
if( !\count( $data['services'] ) )
{
/* Temporarily set the tokens so that we can properly make the next call */
\IPS\Settings::i()->verify_sid = $values['verify_sid'];
\IPS\Settings::i()->verify_token = $values['verify_token'];
$service = static::_api( "Services", 'post', array(
'FriendlyName' => 'IPS MFA'
) );
$values['verify_service_sid'] = $service['sid'];
}
else
{
$values['verify_service_sid'] = $data['services'][0]['sid'];
}
$values['verify_groups'] = ( $values['verify_groups'] == '*' ) ? '*' : implode( ',', $values['verify_groups'] );
$values['verify_setup'] = isset( $values['verify_setup'] ) ? implode( ',', $values['verify_setup'] ) : '';
$values['verify_setup_tries'] = $values['verify_setup_protection'][0];
$values['verify_setup_lockout'] = $values['verify_setup_protection'][1];
unset( $values['verify_setup_protection'] );
$values['verify_method'] = isset( $values['verify_method'] ) ? implode( ',', $values['verify_method'] ) : '';
$form->saveAsSettings( $values );
\IPS\Session::i()->log( 'acplogs__mfa_handler_enabled', array( "mfa_verify_title" => TRUE ) );
\IPS\Output::i()->redirect( \IPS\Http\Url::internal( 'app=core&module=settings&controller=mfa' ), 'saved' );
}
catch( \DomainException $e )
{
$form->error = $e->getMessage();
}
}
return (string) $form;
}
/**
* Verify API Keys
*
* @param string $sid The Verify SID
* @param string $token The Verify Auth Token
* @return array
* @throws \DomainException
*/
public static function verifyApiKeys( string $sid, string $token ) : array
{
try
{
$response = \IPS\Http\Url::external("https://verify.twilio.com/v2/Services" )
->request()
->login( $sid, $token )
->get()
->decodeJson();
if( !isset( $response['services'] ) )
{
throw new \DomainException( $response['message'] );
}
return $response;
}
catch ( \IPS\Http\Request\Exception $e )
{
throw new \DomainException( $e->getMessage() );
}
}
/**
* If member has configured this handler, disable it
*
* @param \IPS\Member $member The member
* @return bool
*/
public function disableHandlerForMember( \IPS\Member $member )
{
$mfaDetails = $member->mfa_details;
if ( isset( $mfaDetails['verify'] ) )
{
unset( $mfaDetails['verify'] );
$member->mfa_details = $mfaDetails;
$member->save();
/* Log MFA Disable */
$member->logHistory( 'core', 'mfa', array( 'handler' => $this->key, 'enable' => FALSE ) );
}
}
/**
* Make API Call
*
* @param string $endpoint The endpoint to call
* @param string $method 'get' or 'post'
* @param array|null $data Post data or additional query string parameters
* @return array
* @throws \IPS\MFA\Verify\Exception
*/
protected static function _api( string $endpoint, string $method='get', ?array $data=NULL ) : array
{
$url = \IPS\Http\Url::external("https://verify.twilio.com/v2/{$endpoint}" );
if ( $method == 'get' )
{
$response = $url->setQueryString( $data )
->request()
->login( \IPS\Settings::i()->verify_sid, \IPS\Settings::i()->verify_token )
->get();
}
else
{
$response = $url->request()
->login( \IPS\Settings::i()->verify_sid, \IPS\Settings::i()->verify_token )
->post( $data );
}
$response = $response->decodeJson();
if( isset( $response['code'] ) AND isset( $response['message'] ) )
{
throw new \IPS\MFA\Verify\Exception( $response['message'], $response['code'] );
}
return $response;
}
}
+256 -172
View File
@@ -539,202 +539,240 @@ class _Club extends \IPS\Patterns\ActiveRecord implements \IPS\Content\Embeddabl
{
$form->add( new \IPS\Helpers\Form\YesNo( 'club_rules_reacknowledge', FALSE, FALSE, [], NULL, NULL, NULL, 'club_rules_reacknowledge' ) );
}
if ( $values = $form->values() )
return $form;
}
/**
* Process Club Form
*
* @param $values
* @param bool $acp TRUE if editing in the ACP
* @param bool $new TRUE if creating new
* @param array|null $availableTypes If creating new, the available types
* @return \IPS\Helpers\Form|NULL
*/
public function processForm($values, bool $acp, bool $new = FALSE, array $availableTypes = NULL): ?\IPS\Helpers\Form
{
$this->name = $values['club_name'];
/* If there is only one type available, set it. */
if( \is_array( $availableTypes ) AND \count( $availableTypes ) == 1 )
{
$this->name = $values['club_name'];
$values['club_type'] = key( $availableTypes );
}
/* If there is only one type available, set it. */
if( \is_array( $availableTypes ) AND \count( $availableTypes ) == 1 )
{
$values['club_type'] = key( $availableTypes );
}
$needToUpdatePermissions = FALSE;
if ( $acp )
{
if ( $this->type != $values['club_type'] )
{
$this->type = $values['club_type'];
$needToUpdatePermissions = TRUE;
}
if ( $this->owner != $values['club_owner'] )
{
$this->owner = $values['club_owner'];
$this->addMember( $values['club_owner'], \IPS\Member\Club::STATUS_LEADER, TRUE );
}
}
elseif ( $new )
$needToUpdatePermissions = FALSE;
if ( $acp )
{
if ( $this->type != $values['club_type'] )
{
$this->type = $values['club_type'];
$this->owner = \IPS\Member::loggedIn();
$needToUpdatePermissions = TRUE;
}
$this->about = $values['club_about'];
$this->profile_photo = (string) $values['club_profile_photo'];
if( $values['club_profile_photo'] )
if ( $this->owner != $values['club_owner'] )
{
$this->profile_photo_uncropped = (string) $values['club_profile_photo'];
$this->owner = $values['club_owner'];
$this->addMember( $values['club_owner'], Club::STATUS_LEADER, TRUE );
/* Update purchases for commission */
if( \IPS\Application::appIsEnabled( 'nexus' ) and \IPS\Settings::i()->clubs_paid_on )
{
\IPS\Db::i()->update( 'nexus_purchases', array( 'ps_pay_to' => $this->owner ), array( "ps_pay_to IS NOT NULL and ps_app=? and ps_type=? and ps_item_id=?", 'core', 'club', $this->id ) );
}
}
if ( isset( $values['club_location'] ) )
}
elseif ( $new )
{
$this->type = $values['club_type'];
$this->owner = \IPS\Member::loggedIn();
}
$this->about = $values['club_about'];
$this->profile_photo = (string) $values['club_profile_photo'];
if( $values['club_profile_photo'] )
{
$this->profile_photo_uncropped = (string) $values['club_profile_photo'];
}
if ( isset( $values['club_location'] ) )
{
$this->location_json = json_encode( $values['club_location'] );
if ( $values['club_location']->lat and $values['club_location']->long )
{
$this->location_json = json_encode( $values['club_location'] );
if ( $values['club_location']->lat and $values['club_location']->long )
{
$this->location_lat = $values['club_location']->lat;
$this->location_long = $values['club_location']->long;
}
else
{
$this->location_lat = NULL;
$this->location_long = NULL;
}
$this->location_lat = $values['club_location']->lat;
$this->location_long = $values['club_location']->long;
}
else
{
$this->location_json = NULL;
$this->location_lat = NULL;
$this->location_long = NULL;
}
if ( \IPS\Application::appIsEnabled( 'nexus' ) and \IPS\Settings::i()->clubs_paid_on and \IPS\Member::loggedIn()->group['gbw_paid_clubs'] )
{
switch ( $values['club_membership_fee'] )
{
case 'free':
$this->fee = NULL;
$this->renewal_term = 0;
$this->renewal_units = NULL;
$this->renewal_price = NULL;
break;
case 'paid':
$this->fee = json_encode( $values['club_fee'] );
if ( $values['club_renewals'] and $values['club_renewal_term'] )
{
$term = $values['club_renewal_term']->getTerm();
$this->renewal_term = $term['term'];
$this->renewal_units = $term['unit'];
$this->renewal_price = json_encode( $values['club_renewal_term']->cost );
}
else
{
$this->renewal_term = 0;
$this->renewal_units = NULL;
$this->renewal_price = NULL;
}
break;
}
}
}
else
{
$this->location_json = NULL;
$this->location_lat = NULL;
$this->location_long = NULL;
}
if ( array_key_exists( 'club_show_membertab', $values ) )
if ( \IPS\Application::appIsEnabled( 'nexus' ) and \IPS\Settings::i()->clubs_paid_on and \IPS\Member::loggedIn()->group['gbw_paid_clubs'] )
{
switch ( $values['club_membership_fee'] )
{
$this->show_membertab = $values['club_show_membertab'];
}
else
{
/* Default is "Everybody" */
$this->show_membertab = "nonmember";
}
case 'free':
$this->fee = NULL;
$this->renewal_term = 0;
$this->renewal_units = NULL;
$this->renewal_price = NULL;
break;
$this->save();
if ( $values['show_rules'] )
{
$this->rules = $values['club_rules'];
\IPS\File::claimAttachments( ( $new ) ? "club-rules-new" : "club-rules-{$this->id}", $this->id, NULL, 'rules' );
$this->rules_required = (bool) $values['club_rules_required'];
/* Do we need to reset the acknowledge flags? */
if ( !$new AND \array_key_exists( 'club_rules_reacknowledge', $values ) )
{
if ( $values['club_rules_reacknowledge'] )
case 'paid':
$this->fee = json_encode( $values['club_fee'] );
if ( $values['club_renewals'] and $values['club_renewal_term'] )
{
\IPS\Db::i()->update( 'core_clubs_memberships', array( "rules_acknowledged" => FALSE ), array( "club_id=?", $this->id ) );
}
}
}
else
{
$this->rules = NULL;
$this->rules_required = FALSE;
if ( !$new )
{
/* If this isn't a new club, update all memberships in case they decide to re-add rules later on */
\IPS\Db::i()->update( "core_clubs_memberships", array( 'rules_acknowledged' => FALSE ), array( "club_id=?", $this->id ) );
}
}
if ( $new )
{
$this->addMember( \IPS\Member::loggedIn(), \IPS\Member\Club::STATUS_LEADER );
$this->acknowledgeRules( \IPS\Member::loggedIn() );
}
$this->recountMembers();
$customFieldValues = array();
foreach ( \IPS\Member\Club\CustomField::roots() as $field )
{
if ( isset( $values["core_clubfield_{$field->id}"] ) )
{
$helper = $field->buildHelper();
if ( $helper instanceof \IPS\Helpers\Form\Upload )
{
$customFieldValues[ "field_{$field->id}" ] = (string) $values["core_clubfield_{$field->id}"];
$term = $values['club_renewal_term']->getTerm();
$this->renewal_term = $term['term'];
$this->renewal_units = $term['unit'];
$this->renewal_price = json_encode( $values['club_renewal_term']->cost );
}
else
{
$customFieldValues[ "field_{$field->id}" ] = $helper::stringValue( $values["core_clubfield_{$field->id}"] );
$this->renewal_term = 0;
$this->renewal_units = NULL;
$this->renewal_price = NULL;
}
if ( $field->type === 'Editor' )
{
$field->claimAttachments( $this->id );
}
}
break;
}
if ( \count( $customFieldValues ) )
{
$customFieldValues['club_id'] = $this->id;
\IPS\Db::i()->insert( 'core_clubs_fieldvalues', $customFieldValues, TRUE );
}
if ( $needToUpdatePermissions )
{
foreach ( $this->nodes() as $node )
{
try
{
$nodeClass = $node['node_class'];
$node = $nodeClass::load( $node['node_id'] );
$node->setPermissionsToClub( $this );
}
catch ( \Exception $e ) { }
}
}
if( $new and \IPS\Settings::i()->clubs_require_approval and !$this->approved )
{
$this->sendModeratorApprovalNotification();
}
if( $new and $this->approved )
{
\IPS\Api\Webhook::fire( 'club_created', $this );
}
else if( !$new AND isset( $this->changed['approved'] ) )
{
$this->onApprove();
}
return NULL;
}
return $form;
if ( array_key_exists( 'club_show_membertab', $values ) )
{
$this->show_membertab = $values['club_show_membertab'];
}
else
{
/* Default is "Everybody" */
$this->show_membertab = "nonmember";
}
$this->save();
if ( $values['show_rules'] )
{
$this->rules = $values['club_rules'];
\IPS\File::claimAttachments( ( $new ) ? "club-rules-new" : "club-rules-{$this->id}", $this->id, NULL, 'rules' );
$this->rules_required = (bool) $values['club_rules_required'];
/* Do we need to reset the acknowledge flags? */
if ( !$new AND \array_key_exists( 'club_rules_reacknowledge', $values ) )
{
if ( $values['club_rules_reacknowledge'] )
{
\IPS\Db::i()->update( 'core_clubs_memberships', array( "rules_acknowledged" => FALSE ), array( "club_id=?", $this->id ) );
}
}
}
else
{
$this->rules = NULL;
$this->rules_required = FALSE;
if ( !$new )
{
/* If this isn't a new club, update all memberships in case they decide to re-add rules later on */
\IPS\Db::i()->update( "core_clubs_memberships", array( 'rules_acknowledged' => FALSE ), array( "club_id=?", $this->id ) );
}
}
if ( $new )
{
$this->addMember( \IPS\Member::loggedIn(), Club::STATUS_LEADER );
$this->acknowledgeRules( \IPS\Member::loggedIn() );
}
$this->recountMembers();
$customFieldValues = array();
foreach ( \IPS\Member\Club\CustomField::roots() as $field )
{
if ( isset( $values["core_clubfield_{$field->id}"] ) )
{
$helper = $field->buildHelper();
if ( $helper instanceof \IPS\Helpers\Form\Upload )
{
$customFieldValues[ "field_{$field->id}" ] = (string) $values["core_clubfield_{$field->id}"];
}
else
{
$customFieldValues[ "field_{$field->id}" ] = $helper::stringValue( $values["core_clubfield_{$field->id}"] );
}
if ( $field->type === 'Editor' )
{
$field->claimAttachments( $this->id );
}
}
}
if ( \count( $customFieldValues ) )
{
$customFieldValues['club_id'] = $this->id;
\IPS\Db::i()->insert( 'core_clubs_fieldvalues', $customFieldValues, TRUE );
}
if ( $needToUpdatePermissions )
{
foreach ( $this->nodes() as $node )
{
try
{
$nodeClass = $node['node_class'];
$node = $nodeClass::load( $node['node_id'] );
$node->setPermissionsToClub( $this );
}
catch ( \Exception $e ) { }
}
}
if( $new and \IPS\Settings::i()->clubs_require_approval and !$this->approved )
{
$this->sendModeratorApprovalNotification();
}
if( $new and $this->approved )
{
\IPS\Api\Webhook::fire( 'club_created', $this );
}
else if( !$new AND isset( $this->changed['approved'] ) )
{
$this->onApprove();
}
return NULL;
}
/**
* Redirect after save
*
* @param \IPS\Member\Club $old A clone of the club as it was before
* @param \IPS\Member\Club\ $new The club now
* @return array
*/
public static function renewalChanges(Club $old, Club $new)
{
$changes = array();
foreach ( array( 'renewal_term', 'renewal_units', 'renewal_price' ) as $k )
{
if ( $old->$k != $new->$k )
{
$changes[ $k ] = $old->$k;
}
}
return $changes;
}
/**
@@ -2191,4 +2229,50 @@ class _Club extends \IPS\Patterns\ActiveRecord implements \IPS\Content\Embeddabl
\IPS\Api\Webhook::fire( 'club_created', $this );
$this->owner->achievementAction( 'core', 'NewClub', $this );
}
/**
* Update existing purchases
*
* @param \IPS\nexus\Purchase $purchase The purchase
* @param array $changes The old values
* @param bool $cancelBillingAgreementIfNecessary If making changes to renewal terms, TRUE will cancel associated billing agreements. FALSE will skip that change
* @return void
*/
public function updatePurchase( \IPS\nexus\Purchase $purchase, $changes=array(), $cancelBillingAgreementIfNecessary=FALSE )
{
$tax = NULL;
if ( $purchase->tax )
{
try
{
$tax = \IPS\nexus\Tax::load( $purchase->tax );
}
catch ( \OutOfRangeException $e ) { }
}
$currency = $purchase->renewal_currency ?: $purchase->member->defaultCurrency( );
$price = json_decode( $this->renewal_price, TRUE );
$purchase->renewals = new \IPS\nexus\Purchase\RenewalTerm(
new \IPS\nexus\Money( $price[$currency]['amount'], $currency ),
new \DateInterval( 'P' . $this->renewal_term . mb_strtoupper( $this->renewal_units ) ),
$tax
);
if ( $cancelBillingAgreementIfNecessary and $billingAgreement = $purchase->billing_agreement )
{
if ( array_key_exists( 'renewal_price', $changes ) and !empty( $changes['renewal_price'] ) )
{
try
{
$billingAgreement->cancel();
$billingAgreement->save();
}
catch ( \Exception $e ) { }
}
}
$purchase->save();
}
}
+1 -1
View File
@@ -262,7 +262,7 @@ class _Device extends \IPS\Patterns\ActiveRecord
{
try
{
$location = \IPS\GeoLocation::getByIp( \IPS\Request::i()->ipAddress() );
$location = \IPS\GeoLocation::getRequesterLocation();
}
catch ( \Exception $e )
{
+334 -12
View File
@@ -120,6 +120,8 @@ class _Member extends \IPS\Patterns\ActiveRecord
'new_device_email' => 16777216, // Send an email when a new device is used to log in.
'no_solved_reenage' => 33554432, // User does not want to get topic re-engagement emails
'datalayer_pii_optout' => 67108864, // User has opted in to having their PII collected by datalayer
'email_messages_bounce' => 134217728, // User's email keeps returning hard bounces so they need to change it
// 268435456 - cookie_optout moved to cookie preferences
)
)
);
@@ -133,7 +135,6 @@ class _Member extends \IPS\Patterns\ActiveRecord
'last_visit',
'last_activity',
'profilesync_lastsync',
];
/* !Follow */
@@ -203,7 +204,12 @@ class _Member extends \IPS\Patterns\ActiveRecord
if ( !static::$loggedInMember->member_id and isset( \IPS\Request::i()->cookie['ipsTimezone'] ) )
{
static::$loggedInMember->timezone = \IPS\DateTime::getFixedTimezone( \IPS\Request::i()->cookie['ipsTimezone'] );
/* Check for valid timezone identifier */
$tz = \IPS\DateTime::getFixedTimezone( \IPS\Request::i()->cookie['ipsTimezone'] );
if( \in_array( $tz, \DateTimeZone::listIdentifiers() ) )
{
static::$loggedInMember->timezone = $tz;
}
}
}
@@ -331,6 +337,47 @@ class _Member extends \IPS\Patterns\ActiveRecord
}
}
/**
* Allow optional cookies
*
* @param bool $allow
* @return bool
*/
public function setAllowOptionalCookies( bool $allow = TRUE ): bool
{
$expire = ( new \IPS\DateTime )->add( new \DateInterval('P6M' ) );
\IPS\Request::i()->setCookie( 'cookie_consent', 1, $expire, FALSE );
if( !$allow )
{
/* Remove optional cookies */
foreach( \IPS\Request::i()->cookie as $cookieName => $value )
{
if( !\in_array( $cookieName, \IPS\Request::i()->getEssentialCookies() ) )
{
\IPS\Request::i()->setCookie( $cookieName, NULL);
}
}
\IPS\Request::i()->setCookie( 'cookie_consent_optional', NULL );
}
else
{
\IPS\Request::i()->setCookie( 'cookie_consent_optional', 1, $expire, FALSE );
}
return $allow;
}
/**
* Are optional cookies allowed?
*
* @return bool
*/
public function get_optionalCookiesAllowed(): bool
{
return isset( \IPS\Request::i()->cookie[ 'cookie_consent' ] ) AND isset( \IPS\Request::i()->cookie[ 'cookie_consent_optional' ] );
}
/**
* [ActiveRecord] Delete Record
*
@@ -411,6 +458,7 @@ class _Member extends \IPS\Patterns\ActiveRecord
/* @note The completed column is added in the 4.4.0 Beta 1 routine, so we need to verify the upgrade has been performed otherwise
the auto-upgrader can replace this file before the column is added, and an SQL error can prevent the admin from completing the
auto-upgrade process */
$fireRegistrationCompletedWebhook = FALSE;
if ( \IPS\Application::load( 'core' )->long_version > 104000 )
{
if( $this->completed AND ( !$this->name OR !$this->email ) )
@@ -421,22 +469,32 @@ class _Member extends \IPS\Patterns\ActiveRecord
elseif( !$this->completed AND $this->name AND $this->email )
{
$this->completed = TRUE;
\IPS\Api\Webhook::fire( 'member_registration_complete', $this, $this->webhookFilters() );
$fireRegistrationCompletedWebhook = TRUE;
}
}
/* If the email was changed, make sure that the bounce flag is unset so the member no longer sees the error message */
if ( isset( $changes['email'] ) )
{
$this->members_bitoptions['email_messages_bounce'] = 0;
}
parent::save();
if ( $new )
{
$this->memberSync( 'onCreateAccount' );
/* Profile Fields */
\IPS\Db::i()->insert( 'core_pfields_content', array( 'member_id' => $this->member_id ), TRUE );
$this->memberSync( 'onCreateAccount' );
}
else
{
if( $fireRegistrationCompletedWebhook )
{
\IPS\Api\Webhook::fire( 'member_registration_complete', $this, $this->webhookFilters() );
}
/* Run member sync, but not if the only change is the last_activity timestamp */
if( \count( $this->changedCustomFields ) > 0 OR ( \count( $changes ) > 0 AND !( \count( $changes ) === 1 AND isset( $changes['last_activity'] ) ) ) )
{
@@ -784,6 +842,12 @@ class _Member extends \IPS\Patterns\ActiveRecord
{
$this->members_bitoptions['validating'] = FALSE;
}
/* Revoke oAuth tokens */
if( $value )
{
\IPS\Db::i()->update( 'core_oauth_server_access_tokens', array( 'status' => 'revoked' ), array( 'member_id=?', $this->member_id ) );
}
}
/**
@@ -2011,7 +2075,14 @@ class _Member extends \IPS\Patterns\ActiveRecord
{
return $this->_lang;
}
/* If in API use the lang set by dispatcher */
if ( \IPS\Dispatcher::hasInstance() and class_exists( 'IPS\Dispatcher', FALSE ) AND \IPS\Dispatcher::i()->controllerLocation === 'api' )
{
$this->_lang = \IPS\Dispatcher::i()->_setLanguage();
return $this->_lang;
}
/* If in setup, create a "dummy" language */
if ( \IPS\Dispatcher::hasInstance() and class_exists( 'IPS\Dispatcher', FALSE ) AND \IPS\Dispatcher::i()->controllerLocation === 'setup' AND \IPS\Dispatcher::i()->setupLocation === 'install' )
{
@@ -3418,7 +3489,6 @@ class _Member extends \IPS\Patterns\ActiveRecord
* @apiresponse string title Member title
* @clientapiresponse string timezone Member timezone
* @apiresponse string formattedName Username with group formatting
* @apiresponse string ipAddress IP address used during registration
* @apiresponse \IPS\Member\Group primaryGroup Primary group
* @clientapiresponse [\IPS\Member\Group] secondaryGroups Secondary groups
* @clientapiresponse string email Email address
@@ -3445,7 +3515,17 @@ class _Member extends \IPS\Patterns\ActiveRecord
*/
public function apiOutput( \IPS\Member $authorizedMember = NULL, $otherFields = NULL )
{
$group = \IPS\Member\Group::load( $this->_data['member_group_id'] );
try
{
$group = \IPS\Member\Group::load( $this->_data['member_group_id'] );
}
catch( \OutOfRangeException $e )
{
\IPS\Log::log( "{$this->name} has an invalid group ({$this->_data['member_group_id']}) during API call. Resetting.", 'api_invalid_group' );
$group = \IPS\Member\Group::load( \IPS\Settings::i()->member_group ); // Intentially no catch here as that means things are really broken.
$this->member_group_id = \IPS\Settings::i()->member_group;
$this->save();
}
$secondaryGroups = array();
foreach ( array_filter( array_map( "intval", explode( ',', $this->_data['mgroup_others'] ) ) ) as $secondaryGroupId )
@@ -3471,7 +3551,8 @@ class _Member extends \IPS\Patterns\ActiveRecord
foreach( $profileFields as $field )
{
if ( !$authorizedMember or
if ( !$authorizedMember or
$field['pf_contains_pii'] == 1 AND ( $authorizedMember->member_id == $this->member_id ) or
$field['pf_member_hide'] == 'all' or
( $field['pf_member_hide'] == 'owner' and ( $authorizedMember->member_id == $this->member_id OR $authorizedMember->modPermissions() OR $authorizedMember->isAdmin() ) ) or
( $field['pf_member_hide'] == 'staff' and ( $authorizedMember->modPermissions() OR $authorizedMember->isAdmin() ) )
@@ -3914,9 +3995,11 @@ class _Member extends \IPS\Patterns\ActiveRecord
* @param string $type Request type
* @param string $emailAddress Email address to check, NULL for existing email address
* @param int $spamCode If set, will modify by reference with the raw value from the spam service
* @param bool $disposable If set, will modify by reference if the user used a disposable email.
* @param bool $geoBlock If set, will modify by reference if the user is in a moderated / blocked country.
* @return int|NULL Action code based on spam service response, or NULL for no action
*/
public function spamService( $type='register', $emailAddress=NULL, &$spamCode=NULL )
public function spamService( $type='register', $emailAddress=NULL, &$spamCode=NULL, &$disposable=FALSE, &$geoBlock=FALSE )
{
$email = $emailAddress ?: $this->email;
@@ -4004,10 +4087,156 @@ class _Member extends \IPS\Patterns\ActiveRecord
break;
}
}
/* GeoLocation */
if ( $type === 'register' AND $action == 1 )
{
$action = $this->geoSpamCheck( $geoBlock );
}
/* If the normal spam service and geolocation didn't pick up anything, check for a disposable email. */
if ( $type === 'register' AND $action == 1 )
{
$action = $this->disposableEmailCheck( $emailAddress, $disposable );
}
return $action;
}
/**
* GeoLocation Block Check
*
* @param bool $geoBlock If set, will modify by reference if the user is in a moderated / blocked country.
* @return int|NULL
*/
public function geoSpamCheck( &$geoBlock ): ?int
{
/* If Geo is enabled, don't bother. */
if ( !\IPS\Settings::i()->ipsgeoip )
{
return 1;
}
/* See we have anything configured. If we don't, don't bother. */
$settings = \IPS\Settings::i()->spam_geo_settings ? json_decode( \IPS\Settings::i()->spam_geo_settings, true ) : array();
if ( !\count( $settings ) )
{
return 1;
}
try
{
$location = \IPS\GeoLocation::getRequesterLocation();
}
catch( \BadMethodCallException | \IPS\Http\Request\Exception | \RuntimeException | \OutOfRangeException $e )
{
/* If it fails for any reason, don't bother. */
\IPS\Log::debug( $e, 'spam-service' );
return 1;
}
/* All good, start checking. */
if ( !isset( $settings[ $location->country ] ) )
{
/* Not in the list, we're good. */
return 1;
}
/* See what action we're taking. */
switch( $settings[$location->country] )
{
case 'moderate':
\IPS\Settings::i()->reg_auth_type = 'admin';
$geoBlock = TRUE;
return 2;
case 'block':
return 4;
}
}
/**
* Check for disposable email address domain
*
* @param string $emailAddress Email to check, or NULL for current.
* @param bool $disposable If set, will modify by reference if the user used a disposable email.
* @return int|NULL
*/
public function disposableEmailCheck( $emailAddress, &$disposable=FALSE ): ?int
{
$email = $emailAddress ?: $this->email;
try
{
$response = \IPS\Http\Url::ips( 'spam/disposable' )->request()->login( \IPS\Settings::i()->ipb_reg_number, '' )->post( array(
'email' => $email,
) );
if ( $response->httpResponseCode != 200 )
{
throw new \DomainException( print_r( $response, TRUE ) );
}
$result = (bool) $response->decodeJson()['result'];
}
catch ( \Exception $e )
{
\IPS\Log::debug( $e, 'spam-service' );
$result = false;
}
$action = NULL;
if ( $result )
{
$disposable = TRUE;
$action = \IPS\Settings::i()->spam_service_disposable;
/* Perform Action */
switch( $action )
{
/* Proceed with registration */
case 1:
break;
/* Flag for admin approval */
case 2:
\IPS\Settings::i()->reg_auth_type = 'admin';
break;
/* Approve the account, but ban it */
case 3:
$this->temp_ban = -1;
$this->members_bitoptions['bw_is_spammer'] = TRUE;
break;
/* Deny registration - we return the code and the controller is expected to show an error */
case 4:
break;
/* Moderate posts */
case 5:
$days = json_decode( \IPS\Settings::i()->spam_service_days, TRUE );
if ( isset( $days['disposable'] ) )
{
if ( $days['disposable'] == -1 )
{
$this->mod_posts = -1;
}
else if ( $days['disposable'] > 0 )
{
$this->mod_posts = \IPS\DateTime::ts( time() )->add( new \DateInterval( "P{$days['disposable']}D" ) )->getTimestamp();
}
}
break;
}
}
return $action;
}
/**
* Member Sync
*
@@ -4597,6 +4826,8 @@ class _Member extends \IPS\Patterns\ActiveRecord
/* Reset the flag */
$this->members_bitoptions['validating'] = FALSE;
$this->save();
\IPS\Api\Webhook::fire( 'member_registration_complete', $this, $this->webhookFilters() );
/* Sync */
$this->memberSync( 'onValidate' );
@@ -5931,7 +6162,7 @@ class _Member extends \IPS\Patterns\ActiveRecord
{
foreach( $members as $data )
{
list( $type, $memberId ) = explode( '-', $data );
[ $type, $memberId ] = explode( '-', $data );
$insertId = \IPS\Db::i()->insert( 'core_achievements_log_milestones', [
'milestone_member_id' => $memberId,
'milestone_rule' => $ruleId,
@@ -6190,4 +6421,95 @@ class _Member extends \IPS\Patterns\ActiveRecord
{
return NULL;
}
/**
* Get the member's personal information
*
* @return \IPS\Xml\SimpleXML
*/
public function getPiiData(): \IPS\Xml\SimpleXML
{
/* Init */
$xml = \IPS\Xml\SimpleXML::create('member_export');
$xml->addAttribute( 'created', \IPS\DateTime::ts( time() )->rfc3339() );
/* Get the data */
foreach( \IPS\Application::allExtensions( 'core', 'MemberExportPersonalInformation', TRUE, 'core' ) AS $key => $ext )
{
if ( $data = $ext->getData( $this ) and \is_array( $data ) and \count( $data ) )
{
$child = $xml->addChild( $key );
foreach( $data as $k => $v )
{
$child->addChild( $k, $v );
}
}
}
return $xml;
}
/**
* Is there a deletion request pending?
*
* @return bool
*/
public function get_isDeletionPending(): bool
{
$where = [];
$where[] = ['member_id=?', $this->member_id];
$where[] = [ \IPS\Db::i()->in( 'action',[\IPS\Member\PrivacyAction::TYPE_REQUEST_DELETE, \IPS\Member\PrivacyAction::TYPE_REQUEST_DELETE_VALIDATION ] ) ];
return !\IPS\Db::i()->select( 'count(*)', \IPS\Member\PrivacyAction::$databaseTable, $where )->first() == 0;
}
/**
* Delete all logged IP addresses which were logged after a certain time
*
* @param int $time
* @return void
*/
public static function pruneAllLoggedIpAddresses(int $time )
{
foreach ( \IPS\Content::routedClasses( FALSE, TRUE ) as $class )
{
try
{
$class::pruneIpAddresses( \IPS\Settings::i()->ip_address_prune );
}
catch( \Exception $ex ) { }
}
/* PMs and Ratings are treated extra */
\IPS\Db::i()->update( 'core_message_posts', array( 'msg_ip_address' => '' ), array( "msg_ip_address != '' AND msg_date <= " . $time ) );
\IPS\Db::i()->update( 'core_ratings', array( 'ip' => '' ), array( "ip != '' AND rating_date IS NOT NULL AND rating_date <= " . $time ) );
foreach ( \IPS\Application::allExtensions( 'core', 'IpAddresses', FALSE, 'core' ) as $key => $extension )
{
if( method_exists( $extension, 'pruneIpAddress'))
{
$extension->pruneIpAddress( $time );
}
}
}
/**
* Can this member request that his account gets deleted via the frontend
*
* @return bool
*/
public function canUseAccountDeletion(): bool
{
if( \IPS\Settings::i()->right_to_be_forgotten_type != 'on' )
{
return FALSE;
}
if( \IPS\Member::loggedIn()->isAdmin() )
{
return FALSE;
}
return TRUE;
}
}
+304
View File
@@ -0,0 +1,304 @@
<?php
/**
* @brief Privacy Action
* @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 07 March 2023
*/
namespace IPS\Member;
/* 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;
}
/**
* PrivacyAction Model
*/
class _PrivacyAction extends \IPS\Patterns\ActiveRecord
{
/**
* @brief [ActiveRecord] Multiton Store
*/
protected static $multitons;
/**
* @brief [ActiveRecord] Database Table
*/
public static $databaseTable = 'core_member_privacy_actions';
/**
* @brief [ActiveRecord] Multiton Map
*/
protected static $multitonMap = [];
/**
* @brief PII request
*/
const TYPE_REQUEST_PII = 'pii_download';
/**
* @brief Account deletion request
*/
const TYPE_REQUEST_DELETE = 'delete_account';
/**
* @brief Account deletion requires validation
*/
const TYPE_REQUEST_DELETE_VALIDATION = 'delete_account_validation';
/**
* Request the PII Data
*
* @param \IPS\Member|NULL $member
* @return void
*/
public static function requestPiiData( \IPS\Member $member = NULL )
{
$member = $member ?: \IPS\Member::loggedIn();
$obj = new static;
$obj->member_id = $member->member_id;
$obj->request_date = time();
$obj->action = static::TYPE_REQUEST_PII;
$obj->save();
\IPS\core\AdminNotification::send( 'core', 'PiiDataRequest', NULL, TRUE, $member );
}
/**
* Can the member request his PII Data?
*
* @param \IPS\Member|NULL $member
* @return bool
*/
public static function canRequestPiiData( \IPS\Member $member = NULL ): bool
{
$member = $member ?: \IPS\Member::loggedIn();
if ( $member->member_id AND !static::hasPiiRequest($member) )
{
return TRUE;
}
return FALSE;
}
/**
* Can the member download his PII Data?
*
* @param \IPS\Member|NULL $member
* @return bool
*/
public static function canDownloadPiiData( \IPS\Member $member = NULL ): bool
{
$member = $member ?: \IPS\Member::loggedIn();
if( !$member->member_id )
{
return FALSE;
}
if ( \IPS\Db::i()->select( 'count(*)', static::$databaseTable, [ 'member_id=? AND action=? AND approved=?', $member->member_id, static::TYPE_REQUEST_PII, 1 ] )->first() > 0 )
{
return TRUE;
}
return FALSE;
}
/**
* Get the deletion request by member and key
*
* @param \IPS\Member $member
* @param string $key
* @return static
* @throws \OutOfRangeException
*/
public static function getDeletionRequestByMemberAndKey( \IPS\Member $member, string $key ): static
{
try
{
$where = [];
$where[] = ['member_id=?', $member->member_id];
$where[] = ['vkey=?', $key];
$where[] = [ \IPS\Db::i()->in( 'action',[static::TYPE_REQUEST_DELETE, static::TYPE_REQUEST_DELETE_VALIDATION ] )];
return static::constructFromData(\IPS\Db::i()->select( '*', static::$databaseTable, $where )->first() );
}
catch( \UnderflowException $e ){
throw new \OutOfRangeException;
}
}
/**
* Is a PII Data Request pending for this member?
*
* @param \IPS\Member|NULL $member
* @param bool $approved
* @return bool
*/
public static function hasPiiRequest( \IPS\Member $member = NULL, bool $approved = FALSE ): bool
{
$member = $member ?: \IPS\Member::loggedIn();
try
{
\IPS\Db::i()->select( '*', static::$databaseTable, [ 'member_id=? AND action=? AND approved=?', $member->member_id, static::TYPE_REQUEST_PII, (int) $approved ] )->first();
return TRUE;
}
catch( \UnderflowException $e ){}
return FALSE;
}
/**
* Approve the PII Request
*
* @return void
*/
public function approvePiiRequest()
{
$this->approved = TRUE;
$this->save();
$notification = new \IPS\Notification( \IPS\Application::load( 'core' ), 'pii_data', $this, array( $this ) );
$notification->recipients->attach( $this->member );
$notification->send();
static::resetPiiAcpNotifications();
}
/**
* Reject the PII Data Request
*
* @return void
*/
public function rejectPiiRequest()
{
$notification = new \IPS\Notification( \IPS\Application::load( 'core' ), 'pii_data_rejected', $this, array( $this ) );
$notification->recipients->attach( $this->member );
$notification->send();
$this->delete();
static::resetPiiAcpNotifications();
}
/**
* Get the member object
*
* @return \IPS\Member
*/
public function get_member(): \IPS\Member
{
return \IPS\Member::load( $this->member_id );
}
/**
* Reset the PII request ACP notifications
*
* @return void
*/
public static function resetPiiAcpNotifications()
{
if ( !\IPS\Db::i()->select( 'COUNT(*)', static::$databaseTable, array( 'action=? AND approved=?', static::TYPE_REQUEST_PII, 0 ) )->first() )
{
\IPS\core\AdminNotification::remove( 'core', 'PiiDataRequest' );
}
}
/**
* Reset the account deletion ACP notifications
*
* @return void
*/
public static function resetDeletionAcpNotifications()
{
if ( !\IPS\Db::i()->select( 'COUNT(*)', static::$databaseTable, array( 'action=?', static::TYPE_REQUEST_DELETE ) )->first() )
{
\IPS\core\AdminNotification::remove( 'core', 'AccountDeletion' );
}
}
/**
* Can the current member request account deletion?
*
* @param \IPS\Member|NULL $member
* @return bool
*/
public static function canDeleteAccount( \IPS\Member $member = NULL ): bool
{
$member = $member ?: \IPS\Member::loggedIn();
return \IPS\Db::i()->select( 'count(*)', static::$databaseTable, [ 'member_id=? AND action=?', $member->member_id, static::TYPE_REQUEST_DELETE ] )->first() == 0;
}
/**
* Create and log the account deletion request
*
* @param \IPS\Member|NULL $member
* @return void
*/
public static function requestAccountDeletion( \IPS\Member $member = NULL )
{
$member = $member ?: \IPS\Member::loggedIn();
$obj = new static;
$obj->member_id = $member->member_id;
$obj->request_date = time();
$obj->action = static::TYPE_REQUEST_DELETE_VALIDATION;
$vkey = md5( $member->members_pass_hash . \IPS\Login::generateRandomString() );
$obj->vkey = $vkey;
\IPS\Email::buildFromTemplate( 'core', 'account_deletion_confirmation', array( $member, $vkey ), \IPS\Email::TYPE_TRANSACTIONAL )->send( $member );
$obj->save();
}
/**
* Confirm the account deletion request
*
* @return void
*/
public function confirmAccountDeletion()
{
$this->action = static::TYPE_REQUEST_DELETE;
\IPS\core\AdminNotification::send( 'core', 'AccountDeletion', NULL, TRUE, $this->member );
$this->member->logHistory( 'core', 'privacy', array( 'type' => 'account_deletion_requested' ) );
$this->save();
}
/**
* Delete the account
*
* @return void
*/
public function deleteAccount()
{
/** @var \IPS\Member $member */
$member = $this->member;
$member->delete( TRUE, FALSE );
static::resetDeletionAcpNotifications();
\IPS\Session::i()->log( 'acplog__members_deleted_id', array( $member->name => FALSE, $member->member_id => FALSE ) );
}
/**
* Reject the account deletion request
*
* @return void
*/
public function rejectDeletionRequest()
{
$notification = new \IPS\Notification( \IPS\Application::load( 'core' ), 'account_del_request_rejected', $this, array( $this ) );
$notification->recipients->attach( $this->member );
$notification->send();
$this->delete();
static::resetDeletionAcpNotifications();
}
/**
* Reset all ACP notifications and caches if there are any, should be called after a member was deleted or merged
*
* @return void
*/
public static function resetAcpNotificationCounts()
{
static::resetPiiAcpNotifications();
static::resetDeletionAcpNotifications();
}
}
+3 -3
View File
@@ -90,7 +90,7 @@ class _NodeController extends \IPS\Api\Controller
* Delete
*
* @param int $id ID Number
* @param int|NULL $deleteChildrenOrMove -1 to delete all child nodes, or the new parent node ID to move the children to
* @param int|NULL $deleteChildrenOrMove -1 to delete all child nodes and content, or the new parent node ID to move the children and content to
* @throws 1S359/1 INVALID_ID The node ID does not exist
* @throws 1S359/2 INVALID_TARGET The target node cannot be deleted because the new parent node does not exist
* @throws 1S359/3 HAS_CHILDREN The target node cannot be deleted because it has children (pass deleteChildrenOrMove in the request to specify how to handle the children)
@@ -104,7 +104,7 @@ class _NodeController extends \IPS\Api\Controller
{
$node = $class::load( $id );
if ( $node->hasChildren( NULL, NULL, TRUE ) )
if ( $node->hasChildren( NULL, NULL, TRUE ) OR ( isset( $node::$contentItemClass ) AND $node::$contentItemClass ) )
{
/* -1 means delete everything */
if ( $deleteChildrenOrMove AND $deleteChildrenOrMove == -1 )
@@ -116,7 +116,7 @@ class _NodeController extends \IPS\Api\Controller
try
{
$target = $class::load( $deleteChildrenOrMove );
$node->deleteOrMoveFormSubmit( array( 'node_move_children' => TRUE, 'node_destination' => $target->_id ) );
$node->deleteOrMoveFormSubmit( array( 'node_move_children' => TRUE, 'node_destination' => $target->_id, 'node_move_content' => $target ) );
}
catch ( \OutOfRangeException $e )
{
+1 -1
View File
@@ -1037,7 +1037,7 @@ class _Notification
/* Check device is logged in */
try
{
$device = \IPS\Member\Device::load( $auth['device'] );
$device = \IPS\Member\Device::load( $auth['device'], NULL, [ 'member_id=?', $auth['member'] ] );
if ( !$device->login_key )
{
/* Is not, continue */
+7 -1
View File
@@ -939,7 +939,13 @@ class _Output
$output = str_replace( '<!--ipsCachingLog-->', \IPS\Theme::i()->getTemplate( 'global', 'core', 'front' )->cachingLog( $cachingLog ), $output );
}
}
/* VLE language bits now parseOutputForDisplay has run */
if ( \IPS\Lang::vleActive() )
{
$output = str_replace( '<!--ipsVleWords-->', 'var ipsVle = ' . json_encode( \IPS\Member::loggedIn()->language()->vleForJson(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS ) . ';', $output );
}
/* Check for any autosave cookies */
if ( \count( \IPS\Request::i()->clearAutoSaveCookie ) )
{
+7 -5
View File
@@ -104,15 +104,17 @@ class _Hook extends \IPS\Patterns\ActiveRecord
/* Work out the contents */
$contents = str_replace(
array(
[
'{abstract}',
'{classname}',
),
array(
'{extended_class}',
],
[
$abstract,
$classname,
),
\file_get_contents( \IPS\ROOT_PATH . '/applications/core/data/defaults/Hook.txt' )
$hook->class
],
\file_get_contents( \IPS\ROOT_PATH . '/applications/core/data/defaults/Hook.txt' )
);
\file_put_contents( $hookDir . "/{$hook->filename}.php", $contents );
+6 -9
View File
@@ -433,7 +433,7 @@ CODE;
}
/* Upgrade */
if( \IPS\Member::loggedIn()->hasAcpRestriction( 'core', 'applications', 'plugins_install' ) AND !$this->marketplace_id AND \IPS\IPS::canManageResources() AND \IPS\IPS::checkThirdParty() )
if( \IPS\Member::loggedIn()->hasAcpRestriction( 'core', 'applications', 'plugins_install' ) AND \IPS\IPS::canManageResources() )
{
$buttons['upgrade'] = array(
'icon' => 'upload',
@@ -481,14 +481,11 @@ CODE;
'link' => \IPS\Http\Url::internal( "app=core&module=applications&controller=plugins&do=developer&id={$this->_id}" ),
);
if( !$this->marketplace_id )
{
$buttons['download'] = array(
'icon' => 'download',
'title' => 'download',
'link' => \IPS\Http\Url::internal("app=core&module=applications&controller=plugins&do=download&id={$this->_id}")->csrf(),
);
}
$buttons['download'] = array(
'icon' => 'download',
'title' => 'download',
'link' => \IPS\Http\Url::internal("app=core&module=applications&controller=plugins&do=download&id={$this->_id}")->csrf(),
);
}
if ( file_exists( \IPS\ROOT_PATH . "/plugins/{$this->location}/{$this->name}_{$this->version_human}.xml" ) AND !\IPS\IN_DEV )
+3 -3
View File
@@ -125,9 +125,9 @@ class _Poll extends \IPS\Patterns\ActiveRecord implements \SplSubject
/**
* Get Poll Close Date
*
* @return \IPS\DateTime
* @return \IPS\DateTime|int
*/
public function get_poll_close_date()
public function get_poll_close_date() : \IPS\DateTime|int
{
if( $this->_pollCloseDateObject instanceof \IPS\DateTime )
{
@@ -680,7 +680,7 @@ class _Poll extends \IPS\Patterns\ActiveRecord implements \SplSubject
'title' => $this->poll_question,
'startDate' => $this->start_date->rfc3339(),
'closed' => (bool) $this->poll_closed,
'closedDate' => ( $this->poll_closed ? $this->poll_close_date->rfc3339() : null ),
'closedDate' => ( ( $this->poll_closed AND $this->poll_close_date instanceof \IPS\DateTime ) ? $this->poll_close_date->rfc3339() : null ),
'public' => (bool) $this->poll_view_voters,
'votes' => $this->votes,
'questions' => $questions
+73 -1
View File
@@ -50,7 +50,7 @@ class _Request extends \IPS\Patterns\Singleton
$this->parseIncomingRecursively( $_GET );
$this->parseIncomingRecursively( $_POST );
}
array_walk_recursive( $_COOKIE, array( $this, 'clean' ) );
/* If we have a cookie prefix, we have to strip it first */
@@ -380,6 +380,16 @@ class _Request extends \IPS\Patterns\Singleton
return $path;
}
/**
* Get essential cookies
*
* @return array|string[] List of essential cookies that can't be skipped
*/
public static function getEssentialCookies(): array
{
return \IPS\Application::getEssentialCookieNames();
}
/**
* Set a cookie
@@ -394,6 +404,13 @@ class _Request extends \IPS\Patterns\Singleton
*/
public function setCookie( $name, $value, $expire=NULL, $httpOnly=TRUE, $domain=NULL, $path=NULL )
{
/* Let's see if this is an optional cookie and if it is one, if the user wants them */
if( $this->skipCookie( $name ) )
{
\IPS\Log::debug('skipping ' . $name, 'cookie' );
return FALSE;
}
/* Work out the path and if cookies should be SSL only */
$sslOnly = FALSE;
if( mb_substr( \IPS\Settings::i()->base_url, 0, 5 ) == 'https' AND \IPS\COOKIE_BYPASS_SSLONLY !== TRUE )
@@ -425,6 +442,61 @@ class _Request extends \IPS\Patterns\Singleton
}
return FALSE;
}
/**
* Should the cookie be set or skipped? Takes the controller location and members cookie preferences into account.
*
* @param string $name
* @return bool
*/
protected function skipCookie( string $name ): bool
{
if( \IPS\Dispatcher::hasInstance() AND \IPS\Dispatcher::i()->controllerLocation === 'admin' )
{
return FALSE;
}
elseif( $this->cookieConsentEnabled() !== TRUE )
{
return FALSE;
}
if( in_array( $name, static::getEssentialCookies() ) )
{
return FALSE;
}
/* Check wildcard cookies */
foreach( static::getEssentialCookies() as $c )
{
if( mb_substr( $c, -1, 1 ) === '*' AND rtrim( $name, '*' ) === $name )
{
return FALSE;
}
}
return ( !\IPS\Member::loggedIn()->optionalCookiesAllowed );
}
/**
* @brief Storage of cookie consent status
*/
protected $_cookieConsentEnabled = NULL;
/**
* Check whether cookie consent is required
*
* @return bool
*/
public function cookieConsentEnabled():? bool
{
// See if cookie consent is enabled
if( \IPS\Dispatcher::hasInstance() AND $this->_cookieConsentEnabled === NULL )
{
$this->_cookieConsentEnabled = ( \IPS\Settings::i()->guest_terms_bar AND mb_strstr( \IPS\Member::loggedIn()->language()->get('guest_terms_bar_text_value'), '%4$s' ) );
}
return $this->_cookieConsentEnabled;
}
/**
* Clear login cookies
+3 -3
View File
@@ -214,12 +214,12 @@ class _Front extends \IPS\Session
{
/* Authenticate */
$device = \IPS\Member\Device::loadAndAuthenticate( \IPS\Request::i()->cookie['device_key'], $member, \IPS\Request::i()->cookie['login_key'] );
/* Set member in session */
$this->member = $member;
/* Refresh the device key cookie */
\IPS\Request::i()->setCookie( 'device_key', \IPS\Request::i()->cookie['device_key'], ( new \IPS\DateTime )->add( new \DateInterval( 'P1Y' ) ) );
/* Set member in session */
$this->member = $member;
$member->recordLogin();
$member->achievementAction( 'core', 'SessionStartDaily' );
+10 -4
View File
@@ -26,7 +26,7 @@ class _Parser
* @brief Regex for detecting an Emoji character
* @note This string is automatically generated by the Emoji Data Builder. Do not modify it manually.
*/
const EMOJI_REGEX = '\x{1F468}\x{200D}\x{2764}\x{FE0F}\x{200D}\x{1F48B}\x{200D}\x{1F468}|\x{1F469}\x{200D}\x{2764}\x{FE0F}\x{200D}\x{1F48B}\x{200D}\x{1F468}|\x{1F469}\x{200D}\x{2764}\x{FE0F}\x{200D}\x{1F48B}\x{200D}\x{1F469}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F91D}\x{200D}\x{1F9D1}|\x{1F3F4}\x{E0067}\x{E0062}\x{E0065}\x{E006E}\x{E0067}\x{E007F}|\x{1F3F4}\x{E0067}\x{E0062}\x{E0077}\x{E006C}\x{E0073}\x{E007F}|\x{1F3F4}\x{E0067}\x{E0062}\x{E0073}\x{E0063}\x{E0074}\x{E007F}|\x{1F468}\x{200D}\x{1F469}\x{200D}\x{1F467}\x{200D}\x{1F466}|\x{1F468}\x{200D}\x{1F469}\x{200D}\x{1F466}\x{200D}\x{1F466}|\x{1F468}\x{200D}\x{1F468}\x{200D}\x{1F467}\x{200D}\x{1F467}|\x{1F468}\x{200D}\x{1F468}\x{200D}\x{1F467}\x{200D}\x{1F466}|\x{1F468}\x{200D}\x{1F468}\x{200D}\x{1F466}\x{200D}\x{1F466}|\x{1F469}\x{200D}\x{1F469}\x{200D}\x{1F466}\x{200D}\x{1F466}|\x{1F469}\x{200D}\x{1F469}\x{200D}\x{1F467}\x{200D}\x{1F466}|\x{1F469}\x{200D}\x{1F469}\x{200D}\x{1F467}\x{200D}\x{1F467}|\x{1F468}\x{200D}\x{1F469}\x{200D}\x{1F467}\x{200D}\x{1F467}|\x{1F3CA}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2696}\x{FE0F}|\x{1F64D}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F64D}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F64B}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F64B}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F647}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F647}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F646}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F646}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2695}\x{FE0F}|\x{1F645}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F645}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9B9}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F3C3}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F3C3}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F64E}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2708}\x{FE0F}|\x{1F9D6}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9D6}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9D7}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9D7}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9D8}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F575}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F575}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9B9}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F93D}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F93D}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F93E}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F93E}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9B8}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F64E}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F6A3}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9CD}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9CF}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F3CB}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F3CB}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F3CC}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F3CC}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F939}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F938}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F938}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9CE}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F937}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F937}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9CE}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F935}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F935}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9CF}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F6A3}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F926}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F926}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F3CA}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F939}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9CD}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F6B6}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F6B6}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2695}\x{FE0F}|\x{1F6B5}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F3C4}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F3C4}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F6B5}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F6B4}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F6B4}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9B8}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F9D8}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2696}\x{FE0F}|\x{1F9DB}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F9DA}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2708}\x{FE0F}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2696Line truncated
const EMOJI_REGEX = '\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2764}\x{FE0F}\x{200D}\x{1F48B}\x{200D}\x{1F468}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2764}\x{FE0F}\x{200D}\x{1F48B}\x{200D}\x{1F468}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2764}\x{FE0F}\x{200D}\x{1F48B}\x{200D}\x{1F469}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2764}\x{FE0F}\x{200D}\x{1F468}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2764}\x{FE0F}\x{200D}\x{1F468}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2764}\x{FE0F}\x{200D}\x{1F469}|\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{1F91D}\x{200D}\x{1F9D1}|\x{1F3F4}\x{E0067}\x{E0062}\x{E0065}\x{E006E}\x{E0067}\x{E007F}|\x{1F3F4}\x{E0067}\x{E0062}\x{E0073}\x{E0063}\x{E0074}\x{E007F}|\x{1F3F4}\x{E0067}\x{E0062}\x{E0077}\x{E006C}\x{E0073}\x{E007F}|\x{1F468}\x{200D}\x{1F468}\x{200D}\x{1F466}\x{200D}\x{1F466}|\x{1F468}\x{200D}\x{1F468}\x{200D}\x{1F467}\x{200D}\x{1F466}|\x{1F468}\x{200D}\x{1F468}\x{200D}\x{1F467}\x{200D}\x{1F467}|\x{1F468}\x{200D}\x{1F469}\x{200D}\x{1F466}\x{200D}\x{1F466}|\x{1F468}\x{200D}\x{1F469}\x{200D}\x{1F467}\x{200D}\x{1F466}|\x{1F468}\x{200D}\x{1F469}\x{200D}\x{1F467}\x{200D}\x{1F467}|\x{1F469}\x{200D}\x{1F469}\x{200D}\x{1F466}\x{200D}\x{1F466}|\x{1F469}\x{200D}\x{1F469}\x{200D}\x{1F467}\x{200D}\x{1F466}|\x{1F469}\x{200D}\x{1F469}\x{200D}\x{1F467}\x{200D}\x{1F467}|\x{1F3C3}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F3C3}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F3C4}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F3C4}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F3CA}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F3CA}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F3CB}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F3CB}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F3CC}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F3CC}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2695}\x{FE0F}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2696}\x{FE0F}|\x{1F468}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2708}\x{FE0F}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2695}\x{FE0F}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2696}\x{FE0F}|\x{1F469}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2708}\x{FE0F}|\x{1F46E}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F46E}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F470}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F470}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F471}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F471}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F473}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F473}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F477}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F477}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F481}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F481}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F482}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F482}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F486}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F486}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F487}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F487}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F575}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F575}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F645}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F645}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F646}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F646}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F647}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F647}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F64B}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F64B}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F64D}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F64D}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F64E}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F64E}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F6A3}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F6A3}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F6B4}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F6B4}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F6B5}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F6B5}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F6B6}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F6B6}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F926}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F926}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F935}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F935}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F937}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F937}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F938}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F938}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2642}\x{FE0F}|\x{1F939}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\x{2640}\x{FE0F}|\x{1F939}[\x{1F3FB}-\x{1F3FF}]?\x{200D}\xLine truncated
/**
* @brief Regex for detecting email addresses
@@ -573,6 +573,10 @@ class _Parser
login form), and general annoyances */
$config->set( 'Attr.AllowedClasses', static::getAllowedCssClasses() );
/* Increase default image width */
$config->set( 'CSS.MaxImgLength', "4800px" );
$config->set( 'HTML.MaxImgLength', "4800" );
/* Callback */
if ( $callback )
{
@@ -785,16 +789,16 @@ class _Parser
site.com/index.php?/topic/1-test/?do=embed
site.com/index.php?app=forums&module=forums&controller=topic&id=1&do=embed
Not Allowed: Anything which goes to anything in an /interface directory - e.g.:
site.com/core/interface/file/attachment.php - this would automatically cause files to be downloaded
site.com/applications/core/interface/file/attachment.php - this would automatically cause files to be downloaded
Not Allowed: Any file traversal to expose FileSystem directories, e.g:
site.com/core/../uploads/monthly_xx_xx/file.js?
site.com/applications/core/../uploads/monthly_xx_xx/file.js?
Not Allowed: URLs to the open proxy:
site.com/index.php?app=core&module=system&controller=redirect
*/
$notAllowed = array();
foreach( \IPS\Application::enabledApplications() as $app )
{
$notAllowed[] = str_replace( '/', '(?:/{1,})', '(?:/{0,})' . preg_quote( $app->directory . '/interface/', '%' ) );
$notAllowed[] = str_replace( '/', '(?:/{1,})', '(?:/{0,})' . preg_quote( 'applications/' . $app->directory . '/interface/', '%' ) );
}
foreach( \IPS\File::getStore() as $configuration )
@@ -3253,6 +3257,8 @@ class _Parser
'collegehumor.com' => array( 'http://www.collegehumor.com/oembed.json', static::EMBED_VIDEO ),
'twitter.com' => array( 'https://publish.twitter.com/oembed', static::EMBED_TWEET ),
'mobile.twitter.com' => array( 'https://publish.twitter.com/oembed', static::EMBED_TWEET ),
'x.com' => array( 'https://publish.x.com/oembed', static::EMBED_TWEET ),
'x.twitter.com' => array( 'https://publish.x.com/oembed', static::EMBED_TWEET ),
'soundcloud.com' => array( 'https://soundcloud.com/oembed', static::EMBED_VIDEO ),
'open.spotify.com' => array( 'https://embed.spotify.com/oembed', static::EMBED_VIDEO ),
'play.spotify.com' => array( 'https://embed.spotify.com/oembed', static::EMBED_VIDEO ),
+6 -9
View File
@@ -1955,15 +1955,12 @@ class _Theme extends \IPS\Node\Model
if ( \IPS\Member::loggedIn()->hasAcpRestriction( 'core', 'customization', 'theme_download_upload' ) AND !\IPS\DEMO_MODE )
{
if( !$this->marketplace_id )
{
$buttons['upload'] = array(
'icon' => 'upload',
'title' => 'theme_set_import',
'link' => \IPS\Http\Url::internal( "app=core&module=customization&controller=themes&do=importForm&id={$this->_id}" ),
'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => \IPS\Member::loggedIn()->language()->addToStack( 'theme_set_import_title', FALSE, array( 'sprintf' => array( $this->_title ) ) ) )
);
}
$buttons['upload'] = array(
'icon' => 'upload',
'title' => 'theme_set_import',
'link' => \IPS\Http\Url::internal( "app=core&module=customization&controller=themes&do=importForm&id={$this->_id}" ),
'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => \IPS\Member::loggedIn()->language()->addToStack( 'theme_set_import_title', FALSE, array( 'sprintf' => array( $this->_title ) ) ) )
);
$buttons['download'] = array(
'icon' => 'download',