Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @since 20 Nov 2017 */ namespace IPS\core; /* To prevent PHP errors (extending class does not exist) revealing path */ use IPS\Application; use IPS\convert\App; use IPS\Data\Store; use IPS\DateTime; use IPS\Db; use IPS\Email; use IPS\Helpers\Form\FormAbstract; use IPS\Http\Url; use IPS\IPS; use IPS\Member; use IPS\Patterns\ActiveRecord; use OutOfRangeException; use function count; use function defined; use function get_class; use function in_array; use function method_exists; use function strlen; use function substr; if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * @brief ACP Member Profile: Block */ abstract class AdminNotification extends ActiveRecord { /** * @brief Dynamic severity * @note Uses more resources than the other types. Use only when the notification may show to some admins but not others */ const SEVERITY_DYNAMIC = 'dynamic'; /** * @brief Optional severity */ const SEVERITY_OPTIONAL = 'optional'; /** * @brief Normal severity */ const SEVERITY_NORMAL = 'normal'; /** * @brief High severity */ const SEVERITY_HIGH = 'high'; /** * @brief Critical severity */ const SEVERITY_CRITICAL = 'critical'; /** * @brief Not dismissable */ const DISMISSIBLE_NO = 'no'; /** * @brief Temporarily dismissable */ const DISMISSIBLE_TEMPORARY = 'temp'; /** * @brief Dismissible until it recurs */ const DISMISSIBLE_UNTIL_RECUR = 'recur'; /** * @brief Fully dismissable */ const DISMISSIBLE_PERMANENT = 'perm'; /** * @brief Styling: error */ const STYLE_ERROR = 'error'; /** * @brief Styling: warning */ const STYLE_WARNING = 'warning'; /** * @brief Styling: information */ const STYLE_INFORMATION = 'information'; /** * @brief Styling: expiring */ const STYLE_EXPIRE = 'expire'; /** * @brief [ActiveRecord] Multiton Store */ protected static array $multitons = array(); /** * @brief [ActiveRecord] Caches * @note Defined cache keys will be cleared automatically as needed */ protected array $caches = array( 'acpNotifications', 'acpNotificationIds' ); /** * @brief [ActiveRecord] Database Table */ public static ?string $databaseTable = 'core_acp_notifications'; /** * Get Number of Notifications * * @param Member|NULL $member The member viewing, or NULL for currently logged in * @param array $severities The severities * @return int */ public static function notificationCount( Member $member = NULL, array $severities = array( 'dynamic', 'optional', 'normal', 'high', 'critical' ) ) : int { $member = $member ?: Member::loggedIn(); $notificationIds = static::notificationIdsForMember( $member ); $return = 0; foreach ( $severities as $s ) { if ( $s === 'dynamic' ) { foreach ( $notificationIds['dynamic'] as $i ) { $notification = static::load( $i ); if ( $notification->dynamicShow( $member ) ) { $return++; } } } else { $return += count( $notificationIds[ $s ] ); } } return $return; } /** * Get Notifications that a particular member can see by severity * * @param Member|NULL $member The member viewing, or NULL for currently logged in * @param array $severities The severities * @return array */ public static function notifications( Member $member = NULL, array $severities = array( 'dynamic', 'optional', 'normal', 'high', 'critical' ) ) : array { $member = $member ?: Member::loggedIn(); $notificationIds = static::notificationIdsForMember( $member ); $return = array(); foreach ( static::allNotifications() as $notification ) { $severity = $notification->severity(); if ( in_array( $severity, $severities ) and in_array( $notification->id, $notificationIds[ $severity ] ) and ( $severity !== static::SEVERITY_DYNAMIC or $notification->dynamicShow( $member ) ) ) { /* Check if this notification should dismiss itself first */ if ( $notification->selfDismiss() ) { $notification->delete(); continue; } $return[ $notification->id ] = $notification; } } return $return; } /** * Get Cached IDs of Notifications that a particular member can see by severity * * @param Member $member The member * @return array */ protected static function notificationIdsForMember( Member $member ) : array { if ( !isset( Store::i()->acpNotificationIds ) or !isset( Store::i()->acpNotificationIds[ $member->member_id ] ) ) { /* Init */ $data = isset( Store::i()->acpNotificationIds ) ? Store::i()->acpNotificationIds : array(); $data[ $member->member_id ] = array( static::SEVERITY_DYNAMIC => array(), static::SEVERITY_OPTIONAL => array(), static::SEVERITY_NORMAL => array(), static::SEVERITY_HIGH => array(), static::SEVERITY_CRITICAL => array() ); /* Get our preferences */ $preferences = iterator_to_array( Db::i()->select( '*', 'core_acp_notifications_preferences', array( '`member`=?', $member->member_id ) )->setKeyField('type') ); /* Get our dismissals */ $dismissals = array(); foreach ( Db::i()->select( '*', 'core_acp_notifcations_dismissals', array( '`member`=?', $member->member_id ) ) as $row ) { $dismissals[ $row['notification'] ] = $row['time']; } /* Loop through them */ foreach ( static::allNotifications() as $notification ) { /* Check we want to see it if it's optional */ $exploded = explode( '\\', get_class( $notification ) ); $key = "{$exploded[1]}_{$exploded[5]}"; $view = isset( $preferences[ $key ] ) ? $preferences[ $key ]['view'] : $notification::defaultValue(); if ( !$view ) { continue; } /* Check we haven't dismissed it */ if ( isset( $dismissals[ $notification->id ] ) ) { if ( $notification->dismissible() === static::DISMISSIBLE_TEMPORARY and $dismissals[ $notification->id ] < ( time() - 86400 ) ) { Db::i()->delete( 'core_acp_notifcations_dismissals', array( 'notification=? AND `member`=?', $notification->id, $member->member_id ) ); } elseif ( $notification->dismissible() === static::DISMISSIBLE_UNTIL_RECUR and $dismissals[ $notification->id ] < $notification->sent->getTimestamp() ) { // Nothing, we're showing it } else { continue; } } /* Check we can see it */ if ( $notification->visibleTo( $member ) ) { $data[ $member->member_id ][ $notification->severity() ][ $notification->id ] = $notification->id; } } Store::i()->acpNotificationIds = $data; } return Store::i()->acpNotificationIds[ $member->member_id ]; } /** * Get Notifications * * @return array */ protected static function allNotifications() : array { if ( !isset( Store::i()->acpNotifications ) ) { Store::i()->acpNotifications = iterator_to_array( Db::i()->select( '*', 'core_acp_notifications', NULL, 'sent DESC' ) ); } $notifications = array(); foreach ( Store::i()->acpNotifications as $notification ) { if( Application::appIsEnabled( $notification['app'] ) ) { $app = Application::load( $notification['app'] ); if( array_key_exists( $notification['ext'], $app->extensions( $app, 'AdminNotifications', false ) ) AND class_exists( 'IPS\\' . $notification['app'] . '\extensions\core\AdminNotifications\\' . IPS::mb_ucfirst( $notification['ext'] ) ) ) { $notificationObject = static::constructFromData( $notification ); $notifications[$notificationObject->id] = $notificationObject; } else { /* Remove orphan entry */ Db::i()->delete( 'core_acp_notifications', ['id=?', $notification['id']] ); } } } return $notifications; } /** * Find Existing Notification * * @param string $app Application key * @param string $extension Extension key * @param string|null $extra Any additional information * @param bool $forceRebuild Force a rebuild of the cache; used primarily for sending, not removing * @return static|null */ public static function find( string $app, string $extension, ?string $extra = NULL, bool $forceRebuild=true ) : ?static { /* Drop the cache before we search, to ensure that we have the latest data */ if( $forceRebuild ) { try { unset( Store::i()->acpNotifications ); } catch( OutOfRangeException ){} } foreach ( static::allNotifications() as $notification ) { if ( $notification->app === $app and $notification->ext === $extension and $notification->extra === $extra ) { return $notification; } } return NULL; } /** * Send Notification * * @param string $app Application key * @param string $extension Extension key * @param string|null $extra Any additional information which persists if the notification is resent * @param bool|null $resend If an existing notification exists, it will be bumped / resent * @param mixed $extraForEmail Any additional information specific to this instance which is used for the email but not saved * @param bool|Member $bypassEmail If TRUE, no email will be sent, regardless of admin preferences - or if a member object, that admin will be skipped. Should only be used if the action is initiated by an admin making an email unnecessary * @param array $additionalData Any additional data to save to the notification * @return void */ public static function send( string $app, string $extension, ?string $extra = NULL, ?bool $resend = TRUE, mixed $extraForEmail = NULL, bool|Member $bypassEmail = FALSE, array $additionalData = [] ) : void { /* Create or update */ if ( $notification = static::find( $app, $extension, $extra ) ) { if ( !$resend ) { return; } $notification->sent = time(); } else { $classname = 'IPS\\' . $app . '\extensions\core\AdminNotifications\\' . IPS::mb_ucfirst( $extension ); $notification = new $classname; $notification->app = $app; $notification->ext = $extension; $notification->extra = $extra; $notification->additionalData = $additionalData; unset( Store::i()->acpNotifications ); } /* Is this a new notification? */ if ( !$notification->_new and !$resend ) { return; } /* Get where clause for email notifications */ $exploded = explode( '\\', get_class( $notification ) ); $key = "{$exploded[1]}_{$exploded[5]}"; $where = array( array( 'type=?', $key ) ); $where[] = $notification->emailWhereClause( $extraForEmail ); /* Save */ $notification->save(); /* Email */ if ( $bypassEmail !== TRUE ) { /* Work out if we need to email this to anyone */ $emailRecipients = array(); foreach ( Db::i()->select( '`member`', 'core_acp_notifications_preferences', $where ) as $memberId ) { $member = Member::load( $memberId ); if ( $member->member_id and $notification->visibleTo( $member ) ) { if ( !( $bypassEmail instanceof Member ) or $bypassEmail->member_id !== $member->member_id ) { $emailRecipients[] = $member; } } } /* And if we do, do it */ if ( count( $emailRecipients ) ) { $email = Email::buildFromTemplate( $exploded[1], 'acp_notification_' . $exploded[5], array( $notification, $extraForEmail ), Email::TYPE_TRANSACTIONAL ); $email->setUnsubscribe( 'core', 'unsubscribeAcpNotification', array( get_class( $notification ) ) ); foreach ( $emailRecipients as $member ) { $email->send( $member ); } } } } /** * Delete Notification * * @param string $app Application key * @param string $extension Extension key * @param string|null $extra Any additional information * @param DateTime|null $newTime If provided, rather than deleting the notification, it will modify it's sent time to the specified time * @return void */ public static function remove( string $app, string $extension, ?string $extra = NULL, ?DateTime $newTime = NULL ) : void { if ( $notification = static::find( $app, $extension, $extra, false ) ) { if ( $newTime ) { $notification->sent = $newTime->getTimestamp(); $notification->save(); } else { $notification->delete(); } } } /** * Construct ActiveRecord from database row * * @param array $data Row from database table * @param bool $updateMultitonStoreIfExists Replace current object in multiton store if it already exists there? * @return ActiveRecord */ public static function constructFromData( array $data, bool $updateMultitonStoreIfExists = TRUE ): ActiveRecord { $classname = 'IPS\\' . $data['app'] . '\extensions\core\AdminNotifications\\' . IPS::mb_ucfirst( $data['ext'] ); /* Initiate an object */ $obj = new $classname; $obj->_new = FALSE; /* Import data */ $databasePrefixLength = strlen( static::$databasePrefix ); foreach ( $data as $k => $v ) { if( static::$databasePrefix AND mb_strpos( $k, static::$databasePrefix ) === 0 ) { $k = substr( $k, $databasePrefixLength ); } $obj->_data[ $k ] = $v; } $obj->changed = array(); /* Init */ if ( method_exists( $obj, 'init' ) ) { $obj->init(); } /* Return */ return $obj; } /** * Set Default Values * * @return void */ public function setDefaultValues() : void { $this->sent = time(); } /** * Set Additional Data * * @param $data * @return void */ public function set_additionalData( $data ): void { $this->_data['additional_data'] = json_encode( $data ); } /** * Get Additional Data * * @return array */ public function get_additionalData(): array { return $this->_data['additional_data'] ? json_decode( $this->_data['additional_data'], TRUE ) : []; } /** * Get sent time * * @return DateTime */ public function get_sent() : DateTime { return DateTime::ts( $this->_data['sent'] ); } /** * @brief Identifier for what to group this notification type with on the settings form */ public static string $group = 'other'; /** * @brief Priority 1-5 (1 being highest) for this group compared to others */ public static int $groupPriority = 5; /** * @brief Priority 1-5 (1 being highest) for this notification type compared to others in the same group */ public static int $itemPriority = 3; /** * Can a member access this type of notification? * * @param Member $member The member * @return bool */ public static function permissionCheck( Member $member ) : bool { return TRUE; } /** * Title for settings * * @return string */ abstract public static function settingsTitle() : string; /** * Can a member view this notification? * * @param Member $member The member * @return bool */ public function visibleTo( Member $member ) : bool { return static::permissionCheck( $member ); } /** * For dynamic notifications: should this show for this member? * * @note This is checked every time the notification shows. Should be lightweight. * @param Member $member The member * @return bool */ public function dynamicShow( Member $member ) : bool { return FALSE; } /** * Should this notification dismiss itself? * * @note This is checked every time the notification shows. Should be lightweight. * @return bool */ public function selfDismiss() : bool { return FALSE; } /** * The default value for if this notification shows in the notification center * * @return bool */ public static function defaultValue() : bool { return TRUE; } /** * Is this type of notification ever optional (controls if it will be selectable as "viewable" in settings) * * @return bool */ public static function mayBeOptional() : bool { return TRUE; } /** * Is this type of notification might recur (controls what options will be available for the email setting) * * @return bool */ public static function mayRecur() : bool { return TRUE; } /** * Custom per-admin setting for if email shoild be sent for this notification * * @param string $key Setting field key * @param mixed $value Current value * @return FormAbstract|NULL */ public static function customEmailConfigurationSetting( string $key, mixed $value ): ?FormAbstract { return NULL; } /** * WHERE clause to use against core_acp_notifications_preferences for fetching members to email * * @param mixed $extraForEmail Any additional information specific to this instance which is used for the email but not saved * @return array */ public function emailWhereClause( mixed $extraForEmail ): array { if ( $this->_new ) { return array( "( email='always' OR email='once' )" ); } else { return array( "email='always'" ); } } /** * Notification Title (full HTML, must be escaped where necessary) * * @return string */ abstract public function title() : string; /** * Notification Subtitle (no HTML) * * @return string|null */ public function subtitle() : ?string { return NULL; } /** * Notification Body (full HTML, must be escaped where necessary) * * @return string|null */ abstract public function body() : ?string; /** * Severity * * @return string */ abstract public function severity() : string; /** * Dismissible? * * @return string */ abstract public function dismissible() : string; /** * Style * * @return string */ public function style() : string { switch ( $this->severity() ) { case static::SEVERITY_CRITICAL: return static::STYLE_ERROR; case static::SEVERITY_HIGH: return static::STYLE_WARNING; default: return static::STYLE_INFORMATION; } } /** * Quick link from popup menu * * @return Url|null */ public function link() : Url|null { return Url::internal( 'app=core&module=overview&controller=notifications&highlightedId=' . $this->id ); } /** * Delete * * @return void */ public function delete(): void { Db::i()->delete( 'core_acp_notifcations_dismissals', array( 'notification=?', $this->id ) ); parent::delete(); } /** * Dismiss a notification for a member and rebuild the datastore * * @param int $notificationId * @param Member|null $member * * @return void */ public static function dismissNotification( int $notificationId, ?Member $member = NULL ) : void { $member = $member ?: Member::loggedIn(); Db::i()->insert( 'core_acp_notifcations_dismissals', array( 'notification' => $notificationId, 'member' => $member->member_id, 'time' => time() ), TRUE ); if( isset( Store::i()->acpNotificationIds ) ) { $notificationCache = Store::i()->acpNotificationIds; if( isset( $notificationCache[ $member->member_id ] ) ) { unset( $notificationCache[ $member->member_id ] ); } Store::i()->acpNotificationIds = $notificationCache; } } }