Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @since 1 Jul 2015 */ namespace IPS\core\Statistics; /* To prevent PHP errors (extending class does not exist) revealing path */ use DateInterval; use InvalidArgumentException; use IPS\Application; use IPS\DateTime; use IPS\Db; use IPS\Helpers\Form; use IPS\Helpers\Form\DateRange; use IPS\Helpers\Form\Select; use IPS\Http\Url; use IPS\Member; use IPS\Output; use IPS\Platform\Bridge; use IPS\Request; use IPS\Settings; use IPS\Theme; use OutOfRangeException; use UnderflowException; use function array_keys; use function defined; use function in_array; use function is_array; use function is_int; use function is_numeric; use function strval; use function time; if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * Statistics Charts */ abstract class Chart { /** * @brief Controller */ public ?string $controller = 'NULL'; /** * Whether this chart can be saved so that extra tabs exist. When this is true, the chart can only be saved to reports * * @var bool */ public bool $noTabs = false; /** * Get Chart * * @param Url $url URL the chart is being shown on. * * @return \IPS\Helpers\Chart */ abstract public function getChart( Url $url ): \IPS\Helpers\Chart; /** * Load from Extension * * @param string $app * @param string $extension * @return static */ public static function loadFromExtension( string $app, string $extension ): static { $extensions = Application::load( $app )->extensions( 'core', 'Statistics' ); if ( in_array( $extension, array_keys( $extensions ) ) ) { return $extensions[ $extension ]; } throw new OutOfRangeException; } /** * Load from Controller * * @param string $controller * @return static * @throws OutOfRangeException */ public static function loadFromController( string $controller ): static { foreach( Application::allExtensions( 'core', 'Statistics', FALSE ) AS $extension ) { if ( $extension->controller AND $extension->controller === $controller ) { return $extension; } } throw new OutOfRangeException; } /** * Construct a saved chart from data * * @param array|int $data Chart ID or pre-loaded chart data. * @param Url $url URL chart is shown on * @param Member|bool $check Check chart is owned by a specific member, or the currently logged in member if TRUE. If FALSE, no permission checking. * * @return \IPS\Helpers\Chart * @throws OutOfRangeException * @throws InvalidArgumentException */ public static function constructMemberChartFromData( array|int $data, Url $url, Member|bool $check = TRUE ): \IPS\Helpers\Chart { try { if ( $check === FALSE ) { if ( !is_array( $data ) ) { $data = Db::i()->select( '*', 'core_saved_charts', array( "id=?", $data ) )->first(); } } else if ( ( $check instanceof Member ) AND $check->member_id ) { if ( !is_array( $data ) ) { $data = Db::i()->select( '*', 'core_saved_charts', array( "chart_id=? AND (chart_member=? OR chart_member IS NULL)", $data, $check->member_id ) )->first(); } else if ( $data['chart_member'] !== $check->member_id and $data['chart_member'] !== null and $data['chart_member'] !== 0 ) { throw new UnderflowException; } } else if ( $check === TRUE AND Member::loggedIn()->member_id ) { if ( !is_array( $data ) ) { $data = Db::i()->select( '*', 'core_saved_charts', array( "chart_id=? AND (chart_member=? OR chart_member IS NULL OR chart_member=0)", $data, Member::loggedIn()->member_id ) )->first(); } else if ( $data['chart_member'] !== Member::loggedIn()->member_id and $data['chart_member'] !== null and $data['chart_member'] !== 0 ) { throw new UnderflowException; } } else { /* If we're here, we were passed a guest object which isn't going to work. Throw a different exception as this should lead directly to a bugfix since this should never happen. */ throw new InvalidArgumentException; } $extension = static::loadFromController( $data['chart_controller'] ); $chart = $extension->getChart( $url ); $currentFilters = array(); foreach( json_decode( $data['chart_configuration'], true ) AS $k => $v ) { if ( mb_substr( $k, 0, 11 ) == 'customform_' ) { $currentFilters[ mb_substr( $k, 11 ) ] = $v; } else { $currentFilters[ $k ] = $v; } } $chart->savedCustomFilters = $currentFilters; $chart->timescale = $data['chart_timescale'] ?? $chart->timescale; $chart->title = $data['chart_title']; $chart->showFilterTabs = FALSE; $chart->showIntervals = FALSE; $chart->showDateRange = FALSE; $dateRange = static::getChartDateRanges( is_numeric( Request::i()->report_id ) ? (int) Request::i()->report_id : null ); $chart->start = $dateRange['start']; $chart->end = $dateRange['end']; return $chart; } catch( UnderflowException ) { /* Chart doesn't exist, or isn't owned by $member */ throw new OutOfRangeException; } } /** * Get charts for Member * * @param Url $url URL * @param bool $idsOnly Return only ID's belonging to the user for lazyloading. * @param Member|null $member Member, or NULL for currently logged in member. * * @return array * @throws InvalidArgumentException */ public static function getChartsForMember( Url $url, bool $idsOnly = FALSE, ?Member $member = NULL ): array { $member ??= Member::loggedIn(); if ( !$member->member_id ) { throw new InvalidArgumentException; } if ( Bridge::i()->featureIsEnabled( "community_health_stats" ) and !empty( $url->queryString['communityhealth'] ) ) { $charts = Bridge::i()->getCommunityHealthCharts(); } else { $return = []; $reportID = is_numeric( Request::i()->report_id ) ? (int) Request::i()->report_id : 0; $where = []; if ( $reportID > 0 ) { $where[] = array( "(chart_member IS NULL OR chart_member=? OR chart_member=0) AND chart_report_id=?", $member->member_id, $reportID ); } else { $where[] = array( "(chart_member IS NULL OR chart_member=0) AND chart_report_id IS NULL", $member->member_id ); } $charts = Db::i()->select( '*', 'core_saved_charts', $where ); } foreach( $charts AS $chart ) { if ( $idsOnly ) { $return[] = $chart['chart_id']; } else { try { $return[$chart['chart_id']] = array( 'chart' => static::constructMemberChartFromData( $chart, $url, $member ), 'data' => $chart ); } catch( OutOfRangeException $e ) { continue; } } } return $return; } /** * Get the saved blocks for the current page. * * @return array */ public static function getSavedBlocks() : array { static $toDisplay = null; if ( !is_array( $toDisplay ) ) { $where = [[ "(stat_member IS NULL OR stat_member=?)", Member::loggedIn()->member_id ]]; if ( Request::i()->report_id ) { $where[] = [ "stat_report_id=?", Request::i()->report_id ]; } else { $where[] = [ "stat_report_id IS NULL" ]; } $allExtensions = Application::allExtensions( 'core', 'OverviewStatistics', TRUE ); $toDisplay = []; foreach ( Db::i()->select( "*", "core_saved_overview_stats", $where ) as $savedStat ) { $key = $savedStat['stat_app'] . "_" . $savedStat['stat_extension']; if ( isset( $allExtensions[ $key ] ) and in_array( $savedStat['stat_subblock'], $allExtensions[ $key ]->getBlocks() ) ) { $details = $allExtensions[ $key ]->getBlockDetails( $savedStat['stat_subblock'] ); $details['title'] = $savedStat['stat_title']; $toDisplay[] = [ "blockKey" => $key, "subblock" => $savedStat['stat_subblock'], "saved_stat_id" => $savedStat['id'], "details" => $details, 'page' => $allExtensions[ $key ]->page, 'extension' => $allExtensions[$key], ]; } } } return $toDisplay; } /** * Get the date range selector that is at the top of the saved reports acp page * @return string */ public static function getBlockDateRangeForm( ?int $reportId=null ) : string { $options = array( '7' => 'last_week', '30' => 'last_month', '90' => 'last_three_months', '180' => 'last_six_months', '365' => 'last_year', '0' => 'alltime', '-1' => 'custom' ); $defaultRange = '7'; $dateRange = null; if ( ( is_numeric( Request::i()->predate ) and isset( $options[Request::i()->predate] ) ) or ( is_numeric( @Request::i()->date['start'] ) and is_numeric( @Request::i()->date['end'] ) ) ) { if ( is_numeric( @Request::i()->date['start'] ) and is_numeric( @Request::i()->date['end'] ) ) { $dateRange = [ 'start' => Request::i()->date['start'], 'end' => Request::i()->date['end'] ]; $defaultRange = '-1'; } else { $defaultRange = Request::i()->predate; } } else if ( is_int( $reportId ) ) { try { $currentReport = Db::i()->select( '*', 'core_reports', ['id=?', $reportId] )->first(); $defaultRange = strval($currentReport['report_range_timescale'] ?? '7'); if ( isset( $currentReport['report_range_start'] ) or isset( $currentReport['report_range_end'] ) ) { $dateRange = [ 'start'=> $currentReport['report_range_start'], 'end' => $currentReport['report_range_end'], ]; } } catch ( \UnderflowException ) {} } $form = new Form( 'posts', 'update' ); $form->add( new Select( 'predate', $defaultRange, FALSE, array( 'options' => $options, 'toggles' => array( '-1' => array( 'dateFilterInputs' ) ) ) ) ); $form->add( new DateRange( 'date', $dateRange, FALSE, array(), NULL, NULL, NULL, 'dateFilterInputs' ) ); $form->class .= " ipsBox ipsPull i-padding_2"; if ( is_int( $reportId ) and $values = $form->values() ) { $update = [ 'report_range_timescale' => (int) $values['predate'], 'report_range_start' => @$values['date']['start']?->getTimestamp() ?: null, 'report_range_end' => @$values['date']['end']?->getTimestamp() ?: null ]; Db::i()->update( 'core_reports', $update, ['id=?', $reportId] ); if ( Request::i()->isAjax() ) { Output::i()->json( ['message' => 'ok'], 200 ); } Output::i()->redirect( (string) Url::internal( 'app=core&module=overview&controller=mycharts' ) ); } return $form->customTemplate( array( Theme::i()->getTemplate( 'stats' ), 'filtersOverviewForm' ) ); } /** * @return array */ public static function getAppsToFilterBy() { static $apps = null; if ( !is_array( $apps ) ) { $blocks = static::getSavedBlocks(); $apps = []; foreach ( $blocks as $block ) { if ( isset( $block['details']['app'] ) and !isset( $apps[ $block['details']['app'] ] ) ) { $apps[ $block['details']['app'] ] = Application::load( $block['details']['app'] ); } } } return $apps; } /** * @param int|null $reportId The ID of the report, or leave null to use the default saved charts function * @return array{start: DateTime|null, end: DateTime|null} */ public static function getChartDateRanges( ?int $reportId=null ) { // It is possible this is included right in the request if ( isset( Request::i()->predate ) and is_numeric( Request::i()->predate ) and intval( Request::i()->predate ) >= 0 ) { $days = (int) Request::i()->predate; if ( $days <= 0 ) { return [ 'start' => null, 'end' => null, ]; } return [ 'end' => DateTime::ts( time() )->setTime( 23, 59, 59 ), 'start' => DateTime::ts( time() )->setTime( 0, 0, 0 )->sub( new DateInterval( "P{$days}D" ) ) ]; } else if ( isset( Request::i()->date['start'] ) and isset( Request::i()->date['end'] ) ) // the way the JS works, the start and end are sent even if it just relies on the predate. { $start = is_numeric( Request::i()->date['start'] ) ? DateTime::ts( (int) Request::i()->date['start'] ) : null; $end = is_numeric( Request::i()->date['end'] ) ? DateTime::ts( (int) Request::i()->date['end'] ) : null; return [ 'start' => $start, 'end' => $end, ]; } if ( is_null( $reportId ) ) { if ( isset( Settings::i()->core_saved_charts_range_timescale ) and is_numeric( Settings::i()->core_saved_charts_range_timescale ) and intval( Settings::i()->core_saved_charts_range_timescale ) >= 0 ) { $days = (int) Settings::i()->core_saved_charts_range_timescale; if ( $days <= 0 ) { return [ 'start' => null, 'end' => null, ]; } return [ 'end' => DateTime::ts( time() )->setTime( 23, 59, 59 ), 'start' => DateTime::ts( time() )->setTime( 0, 0, 0 )->sub( new DateInterval( "P{$days}D" ) ) ]; } $start = is_numeric( Settings::i()->core_saved_charts_range_start ) ? DateTime::ts( (int) Settings::i()->core_saved_charts_range_start ) : null; $end = is_numeric( Settings::i()->core_saved_charts_range_end ) ? DateTime::ts( (int) Settings::i()->core_saved_charts_range_end ) : null; } else { try { $row = Db::i()->select( "*", "core_reports", [ "id=?", $reportId ?: 0 ] )->first(); $start = is_int( $row['report_range_start'] ) ? DateTime::ts( $row['report_range_start'] ) : null; $end = is_int( $row['report_range_end'] ) ? DateTime::ts( $row['end'] ) : null; } catch ( UnderflowException $e ) { $start = null; $end = null; } } return [ "start" => $start, "end" => $end ]; } }