Version 4.7.0

This commit is contained in:
Neo committed 2025-12-19 16:00:35 -08:00
1 parent 517a5e1f70
commit 8ba7d43898
1933 files changed
+65613 -11332

No files matched your search

+83 -3
View File
@@ -206,7 +206,12 @@ class _Admin extends \IPS\Dispatcher\Standard
{
\IPS\Output::i()->error( 'no_module_permission', '2S107/1', 403, '' );
}
if( ! \IPS\Application::appIsEnabled( $this->application->directory ) )
{
\IPS\Output::i()->error( 'requested_route_404', '2S107/5', 404, '' );
}
/* Support is not available for demos */
if ( \IPS\DEMO_MODE AND $this->module->application === 'core' AND $this->module->key === 'support' )
{
@@ -515,7 +520,18 @@ class _Admin extends \IPS\Dispatcher\Standard
return $this->acpTabOrder;
}
/**
* Display a link in a custom format
*
* @param string $url The URL from the menu system
* @return string|null
*/
public function acpMenuCustom( $url ): ?string
{
return NULL;
}
/**
* Do we have permission to use this module?
*
@@ -534,13 +550,77 @@ class _Admin extends \IPS\Dispatcher\Standard
* @param string $key Permission Key
* @param \IPS\Application|null $app Application (NULL will default to current)
* @param \IPS\Module|string|null $module Module (NULL will default to current)
* @param boolean $return Return boolean (true/false) instead of throwing an error
* @return void
*/
public function checkAcpPermission( $key, $app=NULL, $module=NULL )
public function checkAcpPermission( $key, $app=NULL, $module=NULL, $return=FALSE )
{
if ( !\IPS\Member::loggedIn()->hasAcpRestriction( ( $app ?: $this->application ), ( $module ?: $this->module ), $key ) )
{
if ( $return )
{
return FALSE;
}
\IPS\Output::i()->error( 'no_module_permission', '2S107/2', 403, '' );
}
if ( $return )
{
return TRUE;
}
}
/**
* Show switch link
*
* @return boolean
*/
final public static function showSwitchLink(): bool
{
if ( \IPS\CIC )
{
return false;
}
/* Don't show if installation was less than a month ago */
if ( time() < \IPS\Settings::i()->board_start + ( 86400 * 30 ) )
{
return false;
}
if ( ! isset( \IPS\Request::i()->cookie['acpLinkSnooze'] ) )
{
return true;
}
$value = json_decode( \IPS\Request::i()->cookie['acpLinkSnooze'], true );
if ( $value['hits'] < 3 )
{
/* Show every 30 days */
if ( time() < ( $value['lastClick'] + ( 86400 * 30 ) ) )
{
return false;
}
}
else if ( $value['hits'] < 9 )
{
/* Show every 60 days */
if ( time() < ( $value['lastClick'] + ( 86400 * 60 ) ) )
{
return false;
}
}
else
{
/* Show every 90 days */
if ( time() < ( $value['lastClick'] + ( 86400 * 90 ) ) )
{
return false;
}
}
return true;
}
}
+1 -1
View File
@@ -408,7 +408,7 @@ class _Api extends \IPS\Dispatcher
*/
protected function _respond( $response, $httpResponseCode, $oauthError=NULL, $log=FALSE )
{
$headers = ( mb_strtolower( $_SERVER['REQUEST_METHOD'] ) == 'get' ) ? \IPS\Output::getCacheHeaders( time(), 60 ) : \IPS\Output::getNoCacheHeaders();
$headers = ( mb_strtolower( $_SERVER['REQUEST_METHOD'] ) == 'get' AND !$this->rawAccessToken ) ? \IPS\Output::getCacheHeaders( time(), 60 ) : \IPS\Output::getNoCacheHeaders();
if ( $this->rawAccessToken and $oauthError )
{
+17
View File
@@ -31,6 +31,15 @@ abstract class _Controller
* @brief Is this for displaying "content"? Affects if advertisements may be shown
*/
public $isContentPage = TRUE;
/**
* @brief These properties are used to specify datalayer context properties.
*
* @example array(
'community_area' => array( 'value' => 'search', 'odkUpdate' => 'true' )
* )
*/
public static $dataLayerContext = array();
/**
* Constructor
@@ -82,6 +91,14 @@ abstract class _Controller
*/
public function execute()
{
if ( !empty( static::$dataLayerContext ) AND \IPS\Settings::i()->core_datalayer_enabled AND !\IPS\Request::i()->isAjax() )
{
foreach ( static::$dataLayerContext as $property => $data )
{
\IPS\core\DataLayer::i()->addContextProperty( $property, $data['value'], $data['odkUpdate'] ?? false );
}
}
if( \IPS\Request::i()->do and preg_match( '/^[a-zA-Z\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', \IPS\Request::i()->do ) )
{
if ( method_exists( $this, \IPS\Request::i()->do ) or method_exists( $this, '__call' ) )
+18 -2
View File
@@ -26,7 +26,7 @@ abstract class _Dispatcher
* @brief Singleton Instance
*/
protected static $instance = NULL;
/**
* Check if a dispatcher instance is available
*
@@ -170,7 +170,23 @@ abstract class _Dispatcher
else
{
/* Just prefetch this to save a query later */
\IPS\Output::i()->sendOutput( \IPS\Theme::i()->getTemplate( 'global', 'core' )->globalTemplate( \IPS\Output::i()->title, \IPS\Output::i()->output, array( 'app' => \IPS\Dispatcher::i()->application->directory, 'module' => \IPS\Dispatcher::i()->module->key, 'controller' => \IPS\Dispatcher::i()->controller ) ), 200, 'text/html' );
\IPS\Output::i()->sendOutput( \IPS\Theme::i()->getTemplate( 'global', 'core' )->globalTemplate( \IPS\Output::i()->title, \IPS\Output::i()->output, $this->getLocationData() ), 200, 'text/html' );
}
}
/**
* Get an array of data representing the user's current location
* This gets passed to the templates in order to apply some attributes to the body tag
*
* @return array
*/
public function getLocationData()
{
return array(
'app' => \IPS\Dispatcher::i()->application->directory,
'module' => \IPS\Dispatcher::i()->module->key,
'controller' => \IPS\Dispatcher::i()->controller,
'id' => \IPS\Request::i()->id ? (int) \IPS\Request::i()->id : NULL
);
}
}
+216 -85
View File
@@ -191,83 +191,15 @@ class _Front extends \IPS\Dispatcher\Standard
/* Do we need more info from the member or do they need to validate? */
/* This controllers should always be accessible, no matter if the member is awaiting validation or needs to set up the email or name */
$legalControllers = array( 'privacy', 'contact', 'terms', 'embed', 'metatags', 'store', 'checkout', 'subscriptions' );
/* These controllers should always be accessible, no matter if the member is awaiting validation or needs to set up the email or name */
$legalControllers = array( 'privacy', 'contact', 'terms', 'embed', 'metatags', 'subscriptions', 'serviceworker', 'settings' );
/* Do we need more info from the member or do they need to validate? */
if( \IPS\Member::loggedIn()->member_id and $this->controller !== 'language' and $this->controller !== 'theme' and $this->controller !== 'ajax' and !\in_array( $this->controller, $legalControllers ) )
{
/* Need their name or email... */
if( ( \IPS\Member::loggedIn()->real_name === '' or !\IPS\Member::loggedIn()->email ) and $this->controller !== 'register' and $this->controller !== 'login' )
if ( $url = static::doMemberCheck() )
{
\IPS\Output::i()->redirect( \IPS\Http\Url::internal( 'app=core&module=system&controller=register&do=complete' )->setQueryString( 'ref', base64_encode( \IPS\Request::i()->url() ) ) );
}
/* Need them to validate... */
elseif( \IPS\Member::loggedIn()->members_bitoptions['validating'] and $this->controller !== 'register' and $this->controller !== 'login' and $this->controller != 'redirect' )
{
\IPS\Output::i()->redirect( \IPS\Http\Url::internal( 'app=core&module=system&controller=register&do=validating', 'front', 'register' ) );
}
/* Need them to reconfirm terms/privacy policy... */
elseif ( ( \IPS\Member::loggedIn()->members_bitoptions['must_reaccept_privacy'] or \IPS\Member::loggedIn()->members_bitoptions['must_reaccept_terms'] ) and $this->controller !== 'register' and $this->controller !== 'ajax' )
{
\IPS\Output::i()->redirect( \IPS\Http\Url::internal( 'app=core&module=system&controller=register&do=reconfirm', 'front', 'register' )->setQueryString( 'ref', base64_encode( \IPS\Request::i()->url() ) ) );
}
/* Have required profile actions that need completing */
else if ( \IPS\Settings::i()->quick_register AND !\IPS\Member::loggedIn()->members_bitoptions['profile_completed'] AND !\in_array( $this->controller, array( 'register', 'login', 'redirect', 'ajax', 'settings', 'checkout', 'pixabay', 'editor' ) ) AND $completion = \IPS\Member::loggedIn()->profileCompletion() AND \count( $completion['required'] ) )
{
foreach( $completion['required'] AS $id => $completed )
{
if ( $completed === FALSE )
{
\IPS\Output::i()->redirect( \IPS\Http\Url::internal( "app=core&module=system&controller=register&do=finish&_new=1", 'front', 'register' )->setQueryString( 'ref', base64_encode( \IPS\Request::i()->url() ) ) );
}
}
}
/* Need to set up MFA... */
if ( !\in_array( $this->controller, array( 'register', 'login', 'redirect', 'ajax', 'settings', 'embed' ) ) )
{
$haveAcceptableHandlers = FALSE;
$haveConfiguredHandler = FALSE;
foreach ( \IPS\MFA\MFAHandler::handlers() as $key => $handler )
{
if ( $handler->isEnabled() and $handler->memberCanUseHandler( \IPS\Member::loggedIn() ) )
{
$haveAcceptableHandlers = TRUE;
if ( $handler->memberHasConfiguredHandler( \IPS\Member::loggedIn() ) )
{
$haveConfiguredHandler = TRUE;
break;
}
}
}
if ( !$haveConfiguredHandler and $haveAcceptableHandlers )
{
if ( \IPS\Settings::i()->mfa_required_groups == '*' or \IPS\Member::loggedIn()->inGroup( explode( ',', \IPS\Settings::i()->mfa_required_groups ) ) )
{
if ( \IPS\Settings::i()->mfa_required_prompt === 'immediate' )
{
\IPS\Output::i()->redirect( \IPS\Http\Url::internal( 'app=core&module=system&controller=settings&do=initialMfa', 'front', 'settings' )->setQueryString( 'ref', base64_encode( \IPS\Request::i()->url() ) ) );
}
}
elseif ( \IPS\Settings::i()->mfa_optional_prompt === 'immediate' and !\IPS\Member::loggedIn()->members_bitoptions['security_questions_opt_out'] )
{
\IPS\Output::i()->redirect( \IPS\Http\Url::internal( 'app=core&module=system&controller=settings&do=initialMfa', 'front', 'settings' )->setQueryString( 'ref', base64_encode( \IPS\Request::i()->url() ) ) );
}
}
}
/* Need to reset password */
if ( $this->controller !== 'settings' AND ( !isset( \IPS\Request::i()->area ) OR \IPS\Request::i()->area !== 'password' ) )
{
foreach( \IPS\Login::methods() AS $method )
{
if ( $url = $method->forcePasswordResetUrl( \IPS\Member::loggedIn(), \IPS\Request::i()->url() ) )
{
\IPS\Output::i()->redirect( $url );
}
}
\IPS\Output::i()->redirect( $url );
}
}
@@ -287,7 +219,10 @@ class _Front extends \IPS\Dispatcher\Standard
{
\IPS\Output::i()->error( 'requested_route_404', '2S160/5', 404, '' );
}
/* Set up isAnonymous variable for realtime */
\IPS\Output::i()->jsVars['isAnonymous'] = (bool) \IPS\Member::loggedIn()->isOnlineAnonymously();
/* Stuff for output */
if ( !\IPS\Request::i()->isAjax() )
{
@@ -356,7 +291,7 @@ class _Front extends \IPS\Dispatcher\Standard
if ( \IPS\Settings::i()->use_friendly_urls )
{
$url = \IPS\Request::i()->url();
/* Redirect to the "correct" friendly URL if there is one */
if ( !\IPS\Request::i()->isAjax() and mb_strtolower( $_SERVER['REQUEST_METHOD'] ) == 'get' and !\IPS\ENFORCE_ACCESS )
{
@@ -368,13 +303,23 @@ class _Front extends \IPS\Dispatcher\Standard
$correctUrl = $url->correctFriendlyUrl();
}
/* If they are accessing "index.php/whatever", we want "index.php?/whatever */
if ( !( $correctUrl instanceof \IPS\Http\Url ) and $url instanceof \IPS\Http\Url\Internal and mb_strpos( $url->data[ \IPS\Http\Url::COMPONENT_PATH ], '/index.php/' ) !== FALSE )
if ( !( $correctUrl instanceof \IPS\Http\Url ) and $url instanceof \IPS\Http\Url\Internal )
{
$pathFromBaseUrl = ltrim( mb_substr( $url->data[ \IPS\Http\Url::COMPONENT_PATH ], mb_strlen( \IPS\Http\Url::internal('')->data[ \IPS\Http\Url::COMPONENT_PATH ] ) ), '/' );
if ( mb_substr( $pathFromBaseUrl, 0, 10 ) === 'index.php/' )
/* If they are accessing "index.php/whatever", we want "index.php?/whatever */
if ( mb_strpos( $url->data[ \IPS\Http\Url::COMPONENT_PATH ], '/index.php/' ) !== FALSE )
{
$correctUrl = \IPS\Http\Url\Friendly::friendlyUrlFromComponent( 0, trim( mb_substr( $pathFromBaseUrl, 10 ), '/' ), $url->queryString );
if ( mb_substr( $pathFromBaseUrl, 0, 10 ) === 'index.php/' )
{
$correctUrl = \IPS\Http\Url\Friendly::friendlyUrlFromComponent( 0, trim( mb_substr( $pathFromBaseUrl, 10 ), '/' ), $url->queryString );
}
}
else
{
/* If necessary, return any special cases like the robots.txt file */
$this->customResponse( $pathFromBaseUrl );
}
}
@@ -451,16 +396,20 @@ class _Front extends \IPS\Dispatcher\Standard
protected function checkCached()
{
/* If this is a guest and there's a full cached page, we can serve that */
if( mb_strtolower( $_SERVER['REQUEST_METHOD'] ) == 'get' and !isset( \IPS\Request::i()->csrfKey ) and !isset( \IPS\Request::i()->_mfaLogin ) and !\IPS\Session\Front::loggedIn() and !isset( \IPS\Request::i()->cookie['noCache'] ) and \IPS\CACHE_PAGE_TIMEOUT )
if( mb_strtolower( $_SERVER['REQUEST_METHOD'] ) == 'get' AND !isset( \IPS\Request::i()->csrfKey ) AND !isset( \IPS\Request::i()->_mfaLogin ) AND !\IPS\Session\Front::loggedIn() AND !isset( \IPS\Request::i()->cookie['noCache'] ) AND \IPS\CACHE_PAGE_TIMEOUT )
{
/* Check whether a 304 Not modified response is appropriate */
if( !empty( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) AND ( new \IPS\DateTime( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) )->getTimestamp() > ( time() - \IPS\CACHE_PAGE_TIMEOUT ) )
try
{
/* This is intentionally using PHP methods to avoid database connections */
$this->destruct = false;
header('HTTP/1.1 304 Not Modified' );
exit;
if( !empty( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) AND ( new \IPS\DateTime( \rtrim( $_SERVER['HTTP_IF_MODIFIED_SINCE'], ';' ) ) )->getTimestamp() > ( time() - \IPS\CACHE_PAGE_TIMEOUT ) )
{
/* This is intentionally using PHP methods to avoid database connections */
$this->destruct = false;
header('HTTP/1.1 304 Not Modified' );
exit;
}
}
catch ( \Exception $e ) { }
/* Only check the output cache if it is enabled */
if( empty( \IPS\OUTPUT_CACHE_METHOD ) OR ( !empty( \IPS\OUTPUT_CACHE_METHOD ) AND \IPS\OUTPUT_CACHE_METHOD == 'None' ) )
@@ -660,18 +609,24 @@ class _Front extends \IPS\Dispatcher\Standard
}
}
/* Do things if we're actively using the easy theme editor */
\IPS\Theme::easyModePreOutput();
/* Meta tags */
\IPS\Output::i()->buildMetaTags();
/* Check MFA */
$this->checkMfa();
/* Check Alerts */
$this->checkAlerts();
/* Finish */
parent::finish();
}
/**
* Check MFA to see if we need to supply a code
* Check MFA to see if we need to supply a code. If the member elected to cancel, cancel (and redirect) here
*
* @param boolean $return Return any HTML (true) or add to Output (false)
*
@@ -685,6 +640,37 @@ class _Front extends \IPS\Dispatcher\Standard
$device = \IPS\Member\Device::loadOrCreate( $member, FALSE );
if ( $output = \IPS\MFA\MFAHandler::accessToArea( 'core', $device->known ? 'AuthenticateFrontKnown' : 'AuthenticateFront', \IPS\Request::i()->url(), $member ) )
{
/* Did we just cancel? */
if ( \IPS\Request::i()->_mfaCancel and ( ! \IPS\Member::loggedIn()->member_id or ( \IPS\Member::loggedIn()->member_id === $member->member_id ) ) )
{
/* We don't need this until we re-enter the MFA flow again */
unset( $_SESSION['processing2FA'] );
/* Is MFA required for this member? */
$mfaRequired = \IPS\Settings::i()->mfa_required_groups === '*' or $member->inGroup( explode( ',', \IPS\Settings::i()->mfa_required_groups ) );
/* Does this member require MFA upon login? */
$logout = \IPS\Settings::i()->mfa_required_prompt === 'immediate' and $mfaRequired;
/* Can they see this page without MFA? */
if ( !$mfaRequired OR ( $logout and $this->application->allowGuestAccess( $this->module, $this->controller, \IPS\Request::i()->do ) ) )
{
$redirectUrl = \IPS\Request::i()->url()->stripQueryString([ '_mfaCancel', '_mfaLogin', '_fromLogin', 'csrfKey' ]);
}
else
{
$redirectUrl = \IPS\Http\Url::internal( '' );
}
if ( $logout )
{
\IPS\Login::logout( $redirectUrl );
$redirectUrl = $redirectUrl->setQueryString( '_fromLogout', 1 );
}
\IPS\Output::i()->redirect( $redirectUrl );
}
if ( $return )
{
return $output;
@@ -699,6 +685,103 @@ class _Front extends \IPS\Dispatcher\Standard
}
}
/**
* Show a robots.txt file if configured to do so
*
* @param string $pathFromBaseUrl
*/
public function customResponse( string $pathFromBaseUrl )
{
if ( $pathFromBaseUrl === 'robots.txt' )
{
$this->robotsTxt();
}
else if ( \IPS\core\IndexNow::i()->isEnabled() AND $pathFromBaseUrl === \IPS\core\IndexNow::i()->getKeyFileName() )
{
$this->indexNow();
}
}
/**
* Return the IndexNow key.
*
* @return mixed
*/
protected function indexNow()
{
\IPS\Output::i()->sendOutput( \IPS\core\IndexNow::i()->getKeyfileContent(), 200, 'text/plain' );
}
/**
* Return the robots.txt files
*
* @return mixed
*/
protected function robotsTxt()
{
if ( \IPS\Settings::i()->robots_txt == 'default' )
{
\IPS\Output::i()->sendOutput( static::robotsTxtRules(), 200, 'text/plain' );
}
else if ( \IPS\Settings::i()->robots_txt != 'off' )
{
\IPS\Output::i()->sendOutput( \IPS\Settings::i()->robots_txt, 200, 'text/plain' );
}
throw new \OutOfRangeException;
}
/**
* Return the text for the robots.txt file
*
* @return string
*/
public static function robotsTxtRules(): string
{
$path = str_replace( '//', '/', '/' . trim( str_replace( 'robots.txt', '', \IPS\Http\Url::createFromString( \IPS\Http\Url::baseUrl() )->data[ \IPS\Http\Url::COMPONENT_PATH ] ), '/' ) . '/' );
$content = <<<FILE
# Rules for Invision Community (https://invisioncommunity.com)
User-Agent: *
# Block pages with no unique content
Disallow: {$path}startTopic/
Disallow: {$path}discover/unread/
Disallow: {$path}markallread/
Disallow: {$path}staff/
Disallow: {$path}online/
Disallow: {$path}discover/
Disallow: {$path}leaderboard/
Disallow: {$path}search/
Disallow: {$path}*?advancedSearchForm=
Disallow: {$path}register/
Disallow: {$path}lostpassword/
Disallow: {$path}login/
# Block faceted pages and 301 redirect pages
Disallow: {$path}*?sortby=
Disallow: {$path}*?filter=
Disallow: {$path}*?tab=
Disallow: {$path}*?do=
Disallow: {$path}*ref=
Disallow: {$path}*?forumId*
# Block profile pages as these have little unique value, consume a lot of crawl time and contain hundreds of 301 links
Disallow: {$path}profile/
FILE;
if ( \IPS\Settings::i()->sitemap_url )
{
$siteMapUrl = ( \IPS\Settings::i()->sitemap_url ) ? rtrim( \IPS\Settings::i()->sitemap_url, '/' ) : rtrim( \IPS\Settings::i()->base_url, '/' ) . '/sitemap.php';
$content .= <<<FILE
# Sitemap URL
Sitemap: {$siteMapUrl}
FILE;
}
return $content;
}
/**
* Output the basic javascript files every page needs
*
@@ -712,6 +795,10 @@ class _Front extends \IPS\Dispatcher\Standard
if ( !\IPS\Request::i()->isAjax() )
{
\IPS\Output::i()->globalControllers[] = 'core.front.core.app';
if ( \IPS\Settings::i()->core_datalayer_enabled )
{
\IPS\Output::i()->globalControllers[] = 'core.front.core.dataLayer';
}
\IPS\Output::i()->jsFiles = array_merge( \IPS\Output::i()->jsFiles, \IPS\Output::i()->js( 'front.js' ) );
\IPS\Output::i()->jsFiles = array_merge( \IPS\Output::i()->jsFiles, \IPS\Output::i()->js( 'front_core.js', 'core', 'front' ) );
@@ -777,4 +864,48 @@ class _Front extends \IPS\Dispatcher\Standard
}
}
}
/**
* Do Member Check
*
* @return \IPS\Http\Url|NULL
*/
protected static function doMemberCheck(): ?\IPS\Http\Url
{
foreach( \IPS\Application::applications() AS $app )
{
if ( $url = $app->doMemberCheck() )
{
return $url;
}
}
return NULL;
}
/**
*
*
* @return void
*/
public function checkAlerts()
{
/* Don't get in the way of validating members */
if ( \IPS\Member::loggedIn()->members_bitoptions['validating'] )
{
return;
}
/* Don't get in the way of the ModCP, registering, logging in, etc */
$ignoreControllers = [ 'modcp', 'register', 'login', 'redirect' ];
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() );
\IPS\Output::i()->cssFiles = array_merge( \IPS\Output::i()->cssFiles, \IPS\Theme::i()->css( 'styles/alerts.css', 'core', 'front' ) );
\IPS\Output::i()->output .= \IPS\Theme::i()->getTemplate( 'alerts', 'core', 'front' )->alertModal( $alert, $url = base64_encode( \IPS\Request::i()->url() ) );
}
return;
}
}