Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @since 09 Dec 2022 */ namespace IPS\core\modules\admin\overview; use DateInterval; use DOMDocument; use DOMElement; use DOMXPath; use Exception; use InvalidArgumentException; use IPS\Application; use IPS\core\Statistics\Chart; use IPS\DateTime; use IPS\Db; use IPS\Dispatcher\Controller; use IPS\Helpers\Chart\Dynamic; use IPS\Helpers\Form; use IPS\Helpers\Form\DateRange; use IPS\Helpers\Form\Select; use IPS\Helpers\Form\Text; use IPS\Helpers\Form\YesNo; use IPS\Http\Url; use IPS\Member; use IPS\Output; use IPS\Platform\Bridge; use IPS\Request; use IPS\Session; use IPS\Settings; use IPS\Theme; use OutOfRangeException; use Throwable; use function array_fill; use function array_key_exists; use function array_merge; use function array_values; use function count; use function defined; use function explode; use function fclose; use function fopen; use function fputcsv; use function in_array; use function is_array; use function is_callable; use function is_iterable; use function is_numeric; use function is_string; use function iterator_to_array; use function ksort; use function max; use function str_replace; use function tempnam; use function time; use function trim; use const IPS\TEMP_DIRECTORY; /* To prevent PHP errors (extending class does not exist) revealing path */ if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * mycharts */ class mycharts extends Controller { /** * @brief Has been CSRF-protected */ public static bool $csrfProtected = TRUE; /** * @brief Allow MySQL RW separation for efficiency */ public static bool $allowRWSeparation = TRUE; /** * @var "charts"|"blocks" */ protected string $activeTab = "charts"; /** * The report id for this request * @var int|null */ protected int|null $reportId = null; protected bool $communityhealth = false; /** * @var string[] */ protected array $tabs = [ "charts" => "statsreports_saved_charts", "blocks" => "statsreports_saved_blocks", ]; /** * Get the report for this request * * @return array|null */ protected function _getReport() { static $report = false; if ( $this->reportId and $report === false ) { try { $report = Db::i()->select( "*", "core_reports", [['id=?', $this->reportId]] )->first(); } catch ( \UnderflowException $e ) { $report = null; } } else if ( $report === false ) { $report = null; } return $report; } protected ?array $chartRow= null; /** * Community health shortcut from the menu system */ protected function communityhealth() : void { if ( !Bridge::i()->featureIsEnabled( 'community_health_stats' ) ) { Output::i()->redirect( Url::internal( "app=core&module=overview&controller=mycharts" ) ); } Request::i()->communityhealth = '1'; $this->communityhealth = true; $this->manage(); } /** * @return void * @throws Exception */ public function execute() : void { if ( !Request::i()->communityhealth and Request::i()->chartId and Bridge::i()->featureIsEnabled( 'community_health_stats' ) ) { $allCharts = Bridge::i()->getCommunityHealthCharts(); foreach( $allCharts as $data ) { if ( $data['chart_id'] == Request::i()->chartId ) { $this->chartRow = $data; break; } } if ( $this->chartRow ) { Request::i()->communityhealth = '1'; } } if ( Request::i()->communityhealth ) { if ( !Bridge::i()->featureIsEnabled( "community_health_stats" ) ) { Output::i()->redirect( Url::internal( "app=core&module=overview&controller=mycharts" ) ); } $this->communityhealth = true; $this->activeTab = "charts"; $this->reportId = null; } else { $this->activeTab = isset( $this->tabs[ Request::i()->tab ] ) ? Request::i()->tab : "charts"; $this->reportId = is_numeric( Request::i()->report_id ) ? (int)Request::i()->report_id : 0; $this->reportId = $this->reportId > 0 ? $this->reportId : null; if ( $this->reportId and !$this->_getReport() ) { Output::i()->redirect( Url::internal( (string)Request::i()->url() )->setQueryString( [ "report_id" => "0" ] ) ); } } parent::execute(); } /** * Manage * * @return void */ protected function manage() : void { $report = $this->_getReport(); Output::i()->title = $report ? $report['report_title'] : Member::loggedIn()->loggedIn()->language()->addToStack( $this->communityhealth ? "communityhealth" : "menu__core_overview_mycharts" ); // Have to generate the form regardless of ajax so the values can be saved $form = Chart::getBlockDateRangeForm( $report ? $report['id'] : null ); if ( !Request::i()->isAjax() ) { Output::i()->output = $form; } if ( $this->activeTab === "charts" ) { $charts = []; foreach( Chart::getChartsForMember( Url::internal( "app=core&module=overview&controller=mycharts" . ( $this->communityhealth ? '&communityhealth=1' : '' ) ), TRUE ) AS $id ) { $charts[] = $id; } if ( !\count( $charts ) ) { $content = Theme::i()->getTemplate( 'stats')->mychartsEmpty(); } else { $content = Theme::i()->getTemplate( 'stats' )->mycharts( $charts ); } } else { $content = Theme::i()->getTemplate( "reports", "core", "admin" )->savedStatBlocks(); } if ( Request::i()->isAjax() ) { Output::i()->sendOutput( $content ); } $this->_addSidebarButtons(); Output::i()->jsFiles = array_merge( Output::i()->jsFiles, Output::i()->js( 'admin_stats.js', 'core' ) ); Output::i()->cssFiles = array_merge( Output::i()->cssFiles, Theme::i()->css( 'system/statistics.css', 'core', 'admin' ) ); Output::i()->cssFiles = array_merge( Output::i()->cssFiles, Theme::i()->css( 'system/reports.css', 'core', 'admin' ) ); $url = Url::internal( "app=core&module=overview&controller=mycharts" ); if ( $this->communityhealth ) { $url = $url->setQueryString( [ 'do' => 'communityhealth' ] ); } else if ( $this->reportId ) { $url = $url->setQueryString( [ "report_id" => $this->reportId ] ); } if ( $this->communityhealth ) { Output::i()->output .= Theme::i()->getTemplate( "global", "core", "admin" )->tabs( ['charts' => 'statsreports_saved_charts'], 'charts', $content, $url ); return; } Output::i()->output .= Theme::i()->getTemplate( "global", "core", "admin" )->tabs( $this->tabs, $this->activeTab, $content, $url ); } /** * Get chart * * @return void */ public function getChart() : void { if ( !isset( Request::i()->chartId ) ) { if ( Request::i()->isAjax() ) { Output::i()->output = ''; } else { Output::i()->redirect( Url::internal( "app=core&module=overview&controller=mycharts" ) ); } } try { /* We first get the date range form to load the chart if it exists */ $report = $this->_getReport(); Chart::getBlockDateRangeForm( $report ? $report['id'] : null ); $chart = Chart::constructMemberChartFromData( $this->chartRow ?: Request::i()->chartId, Url::internal( "app=core&module=overview&controller=mycharts&do=getChart&chartId=" . Request::i()->chartId ) ); $chart->options['height'] = '300px'; // This chart is displayed in a grid with other charts, so we want them all to have the same height Output::i()->output = (string) $chart; } catch( Throwable ) { if ( Request::i()->isAjax() ) { Output::i()->output = ''; } else { Output::i()->redirect( Url::internal( "app=core&module=overview&controller=mycharts" ) ); } } } /** * Add the export and report selector sidebar buttons * * @return void */ protected function _addSidebarButtons() { if ( $this->_getReport() or Bridge::i()->featureIsEnabled( 'analytics_full' ) or Db::i()->select( "count(*) as cnt", 'core_reports' )->first() ) { Output::i()->sidebar['actions']['select_report'] = array( 'primary' => false, 'icon' => 'area-chart', 'title' => 'statsreports_select_report', 'id' => 'elStatsSelectReport', 'menu'=> $this->getReportSelector(), 'link' => '#' ); } Output::i()->sidebar['actions']['download'] = array( 'primary' => true, 'icon' => 'download', 'title' => 'statsreports_download', 'link' => Url::internal( 'app=core&module=overview&controller=mycharts&do=download' )->setQueryString( [ "report_id" => $this->reportId ?: 0, "tab" => $this->activeTab ]), 'data' => array( 'ipstooltip' => '', 'ipstooltip-label' => Member::loggedIn()->language()->addToStack( 'statsreports_download_desc'), 'controller' => 'core.admin.stats.downloadButton' ) ); } /** * Get the dropdown to pick a report * * @return array */ protected function getReportSelector() : array { $return = [ 'main' => [ 'icon' => 'earth-americas', 'title' => 'statsreports_report_maindash', 'link' => Url::internal( "app=core&moduel=overview&controller=mycharts&tab=" . $this->activeTab ) ] ]; if( Bridge::i()->featureIsEnabled( 'community_health_stats' ) ) { $return[ 'health' ] = [ 'icon' => 'heart-pulse', 'title' => 'communityhealth', 'link' => Url::internal( "app=core&module=overview&controller=mycharts&communityhealth=1" ) ]; } foreach( iterator_to_array( Db::i()->select( '*', 'core_reports', order: "report_title ASC" ) ) as $row ) { $return[ 'report_' . $row['id'] ] = [ 'icon' => 'bookmark', 'link' => Url::internal( "app=core&module=overview&controller=mycharts&report_id={$row['id']}&tab={$this->activeTab}" ), 'title' => $row['report_title'] ]; } return $return; } /** * @return void */ protected function saveBlock() { $block = Request::i()->block; $app = Request::i()->blockapp; $subblock = Request::i()->subblock; if ( !$block || !$app || !$subblock ) { Output::i()->json( "No block, sub-block or app specified in the request", 400 ); } $savedBlock = null; if ( $savedId = Request::i()->saved_block_id ) { try { $savedBlock = Db::i()->select( '*', 'core_saved_overview_stats', [ 'id=?', $savedId] )->first(); } catch ( \UnderflowException ) { Output::i()->json( "Cannot find block with id {$savedId}", 404 ); } } $defaultTitle = null; if ( isset( $savedBlock['stat_title'] ) ) { $defaultTitle = $savedBlock['stat_title']; } else { try { $application = Application::load( $app ); $extension = null; foreach ( $application->extensions( 'core', 'OverviewStatistics', true ) as $key => $_extension ) { if ( $key === $block ) { $extension = $_extension; break; } } if ( !$extension ) { throw new OutOfRangeException( "The OverviewStatistics plugin {$block} does not exist for the app {$app}" ); } if ( !in_array( $subblock, $extension->getBlocks() ) ) { throw new OutOfRangeException( "Block {$subblock} not defined for this overview stats plugin" ); } if ( ( $details = $extension->getBlockDetails( $subblock ) ) and isset( $details['title'] ) ) { try { $defaultTitle = Member::loggedIn()->language()->addToStack( $details['title'] ); Member::loggedIn()->language()->parseOutputForDisplay( $defaultTitle ); } catch ( Exception ){} } } catch ( Exception ) { Output::i()->json( "The block or subblock could not be found", 404 ); } } $form = new Form(); $form->class = 'ipsForm--vertical'; $form->add(new Text( "statsreports_saved_block_title", $defaultTitle, true )); if ( !is_array( $savedBlock ) ) { $reports = iterator_to_array( Db::i()->select( "*", "core_reports" ) ); $reportOptions = []; foreach( $reports as $row ) { $reportOptions["report_" . $row['id']] = $row['report_title']; } $form->add( new YesNo( "statsreports_show_all_members", true, false ) ); $form->add( new YesNo( "statsreports_include_in_report", false, false, [ "togglesOn" => [ "statsreports_report_select", empty($reportOptions) ? "statsreports_report_new_title" : "" ] ] ) ); // if there are saved reports, allow user to select one of them if ( !empty( $reportOptions ) ) { // todo an autocomplete field is probably a better UX when there could be dozens of reports saved $form->add( new Select( "statsreports_report", @$reports[0] ? 'report_'.$reports[0]['id'] : false, false, [ "unlimited" => false, "unlimitedLang" => "statsreports_report_new", "unlimitedToggles" => [ 'statsreports_report_new_title' ], "options" => $reportOptions, ], id: 'statsreports_report_select' ) ); } $form->add( new Text( "statsreports_report_new_title", null, false, customValidationCode: (function( $value ) use ( $reportOptions ) { if ( ( empty( $reportOptions ) OR ( isset( Request::i()->statsreports_report_unlimited ) ) ) and !empty( (int) Request::i()->statsreports_include_in_report_checkbox ) and ( !$value OR strlen( $value ) < 5 ) ) { throw new InvalidArgumentException( Member::loggedIn()->language()->addToStack( "statsreports_no_title_error" ) ); } else if ( !empty( $reportOptions ) and in_array( $value, array_values( $reportOptions ) ) ) { throw new InvalidArgumentException( Member::loggedIn()->language()->addToStack( 'statsreports_title_in_use_error' ) ); } }), id: "statsreports_report_new_title" ) ); } if ( $values = $form->values() ) { // first, is there a report this block is being saved to? $reportID = null; if ( @$values['statsreports_include_in_report'] ) { if ( isset( $values['statsreports_report'] ) and $values['statsreports_report'] ) { $reportID = (int) explode( '_', $values['statsreports_report'], 2 )[1]; } else if ( ( empty( $reportOptions ) and !empty( $values['statsreports_report_new_title'] ) ) or @$values['statsreports_report'] === false ) { $title = $values['statsreports_report_new_title']; $reportID = Db::i()->insert( "core_reports", [ "report_title" => $title ] ); } } $insert = [ "stat_app" => $app, "stat_extension" => $block, "stat_subblock" => $subblock, "stat_title" => $values['statsreports_saved_block_title'], "stat_report_id" => $reportID, ]; if ( is_array( $savedBlock ) ) { $insert['id'] = $savedBlock['id']; } else { $insert['stat_member'] = $values['statsreports_show_all_members'] ? null: Member::loggedIn()->member_id; } Db::i()->insert( "core_saved_overview_stats", $insert, true ); if ( Request::i()->isAjax() or Request::i()->frommenu ) { Output::i()->json( [ "message" => "ok", "title" => $insert['stat_title'] ], 201 ); } Output::i()->redirect( Url::internal( "app=core&module=overview&controller=mycharts&do=saveBlock" )->setQueryString( [ 'block' => Request::i()->block, 'subblock' => Request::i()->subblock, 'blockapp' => Request::i()->blockapp ] ) ); } Output::i()->output = (string) $form; } /** * Delete an overview stats block * * @return void * @throws \IPS\Api\Exception */ protected function deleteBlock() { Session::i()->csrfCheck(); if ( !isset( Request::i()->saved_block_id ) or !is_numeric( Request::i()->saved_block_id ) ) { Output::i()->error( "Cannot delete because there is no saved_block_id in the request", 400 ); } $id = (int) Request::i()->saved_block_id; Db::i()->delete( "core_saved_overview_stats", [ "id=?", $id] ); Output::i()->redirect( Url::internal( "app=core&module=overview&controller=mycharts&tab=blocks" ) ); } /** * Download a CSV of this report * @return void */ protected function download() { $csvContents = $this->activeTab === 'charts' ? $this->_compileCharts() : $this->_compileBlocks(); $lang = Member::loggedIn()->language(); $name = $lang->get( 'menu__core_overview_mycharts' ) . ' - ' . $lang->get( $this->activeTab === 'charts' ? 'statsreports_chart_fname' : 'statsreports_block_fname' ); // todo use the report's title when that's implemented /* Compile the data */ $file = tempnam( TEMP_DIRECTORY, 'IPS' ); $fh = fopen( $file, 'w' ); fputcsv( $fh, $csvContents['headers'] ); foreach( $csvContents['data'] as $row ) { fputcsv( $fh, $row ); } fclose( $fh ); $csv = \file_get_contents( $file ); Member::loggedIn()->language()->parseOutputForDisplay( $csv ); // Output::i()->sendOutput( $csv, 200, 'text/plain' ); // for debugging Output::i()->sendOutput( $csv, 200, 'text/csv', array( 'Content-Disposition' => Output::getContentDisposition( 'attachment', "{$name}.csv" ) ), FALSE, FALSE, FALSE ); } /** * Get all the charts in this report merged into one array * * @return array{headers: string[], data: string[][]} */ protected function _compileCharts() : array { $dateRange = $this->_getDateRange(); $data = [ "headers" => [ Member::loggedIn()->language()->addToStack( 'date')], "data" => [] ]; Member::loggedIn()->language()->parseOutputForDisplay( $data['headers'][0] ); $rowsByDate = []; $timescale = null; $totalColumnCount = 0; // the type needs to be set before the charts are initialized if ( isset( Request::i()->types ) and is_iterable( Request::i()->types ) ) { foreach( Request::i()->types as $identifier => $type ) { Request::i()->types[ $identifier][ $type] = "AreaChart"; } } $url = Url::internal( "app=core&module=overview&controller=mycharts" ); Dynamic::$mergedChartType = 'AreaChart'; foreach( Chart::getChartsForMember( $url ) AS $chartData ) { $chart = $chartData['chart']; if ( !( $chart instanceof Dynamic ) ) { continue; } $timescale = $timescale ?: $chart->timescale; // Cannot add pie charts if ( $chart->type === 'PieChart' or $chart->type === 'GeoChart' ) { continue; // todo this should probably be indicated to the user if the chart cannot be included in the CSV } $chart->timescale = $timescale; $chart->start = $dateRange['start']; $chart->end = $dateRange['end']; $chart->compileForOutput(); $rawHeaders = $chart->headers; $columnCount = 0; foreach ( $rawHeaders as $header ) { $formattedHeader = $chart->title . ' - ' . str_replace( "\n", ' ', $header['label'] ); Member::loggedIn()->language()->parseOutputForDisplay( $formattedHeader ); // if the first column is a date, it's the indexable date type if ( $columnCount === 0 and in_array( $header['type'], [ 'date', 'datetime', 'timeofday' ] ) ) { continue; } $columnCount++; $data['headers'][] = $formattedHeader; } if ( $columnCount ) { $insertedDates = []; $rawRows = $chart->rows; foreach( $rawRows AS $row ) { $i = 0; $save = array(); $date = null; foreach( $rawHeaders AS $header ) { switch( $header['type'] ) { /* Booleans convert to string 'true' or 'false' */ case 'bool': $save[] = ( $row[$i] ) ? 'true' : 'false'; break; /* Make dates human readable */ case 'date': case 'datetime': case 'timeofday': // In theory, this should be the first column in the chart if ( $i === 0 ) { $date = "ts_" . ( new DateTime( $row[$i] ) )->getTimestamp(); // using ts_ before the date to prevent the arrays from auto-filling numeric indices } else { $save[] = ( new DateTime( $row[ $i ] ) )->fullYearLocaleDate(); } break; /* Anything else can just save as-is */ default: if ( \is_array( $row[$i] ) ) { /* GeoCharts use arrays so we need to use the value rather */ $save[] = $row[$i]['value']; } else { $save[] = $row[$i]; } break; } $i++; } if ( $date ) { $insertedDates[$date] = $date; if ( isset( $rowsByDate[$date] ) ) { $rowsByDate[$date] = array_merge( $rowsByDate[$date], $save ); } else { $rowsByDate[$date] = array_merge( array_fill( 0, $totalColumnCount, "NULL" ), $save ); } } } // after iterating through the rows, we need to make sure we add empty values for dates in the output foreach( $rowsByDate as $rowDate => $rowValues ) { if ( array_key_exists( $rowDate, $insertedDates ) ) { continue; } $rowsByDate[$rowDate] = array_merge( $rowValues, array_fill( 0, $columnCount, "NULL" ) ); } } $totalColumnCount += $columnCount; } ksort( $rowsByDate ); foreach( $rowsByDate as $date => $rowValues ) { // the dates are ts_ $dateString = ( DateTime::ts( (int) \mb_substr( $date, 3 ) ) )->fullYearLocaleDate(); $data['data'][] = array_merge( array( $dateString ), $rowValues ); } return $data; } /** * Get the date range based on the request for a report download * * @return array{start: DateTime, end: DateTime} */ protected function _getDateRange() : array { if ( isset( Request::i()->range ) and is_numeric( Request::i()->range ) ) { $days = (int) Request::i()->range; return [ "start" => DateTime::create()->setTime( 0, 0, 0 )->sub( new DateInterval( "P{$days}D" ) ), "end" => DateTime::create()->setTime( 23, 59, 59 ), ]; } if ( is_array( Request::i()->range ) and is_numeric( @Request::i()->range['start'] ) and is_numeric( @Request::i()->range['end'] ) ) { return [ "start" => DateTime::ts( (int) Request::i()->range['start'] )->setTime( 23, 59, 59 ), "end" => DateTime::ts( (int) Request::i()->range['end'] )->setTime( 0, 0, 0 )->sub( new DateInterval( "P7D" ) ), ]; } return [ "start" => DateTime::create()->setTime( 0, 0, 0 )->sub( new DateInterval( "P7D" ) ), // The default time interval is 7 days "end" => DateTime::create()->setTime( 23, 59, 59 ), ]; } /** * Get all the blocks in this report merged into one array * * @return array{headers: string[], data: string[][]} */ protected function _compileBlocks() : array { $returnData = [ "headers" => [], "data" => [] ]; $rowCount = 0; $columns = []; $dateRange = $this->_getDateRange(); foreach( Chart::getSavedBlocks() as $block ) { $title = Member::loggedIn()->language()->addToStack( $block["details"]["title"] ); $numbersFound = []; if ( is_callable( array( $block['extension'], 'getBlockNumbers' ) ) ) { // Allow extensions to define their own numbers $numbersFound = $block['extension']->getBlockNumbers( $dateRange, $block['subblock'] ); if ( !is_array( $numbersFound ) ) { $numbersFound = [[ Member::loggedIn()->language()->get( "statsreports_legacy_block" ) ]]; } } else { // Here we use the same logic from the JS to find numbers $content = $block['extension']->getBlock( $dateRange, $block['subblock'] ); Member::loggedIn()->language()->parseOutputForDisplay( $content ); $doc = new DOMDocument(); $doc->loadHTML( "\n" . $content . "" ); $path = new DOMXPath( $doc ); $previousPeriodLangString = Member::loggedIn()->language()->addToStack( "previous_period" ); Member::loggedIn()->language()->parseOutputForDisplay( $previousPeriodLangString ); $numbersFound = [[]]; foreach ( ( $path->query( "//*[@data-number]|//*[@title][contains(@class, 'cStat__change')]" ) ?? [] ) as $node ) { if ( $node instanceof DOMElement ) { if ( $node->hasAttribute( "data-number" ) ) { $numbersFound[0][] = trim( $node->getAttribute( "data-number" ) ) ?: 0; } else if ( $node->hasAttribute( "title" ) and ( $titleAttr = $node->getAttribute( "title" ) and str_starts_with( $titleAttr, $previousPeriodLangString ) ) ) { $numbersFound[1][] = trim( str_replace( $previousPeriodLangString, '', $titleAttr ) ) ?: 0; } } } if ( count( $numbersFound ) === 2 ) { $numbersFound = [ "" => $numbersFound[0], "statsreports_previous_count" => $numbersFound[1] ]; } } $numbersFound = is_array( $numbersFound ) ? $numbersFound : [ $numbersFound ]; foreach ( $numbersFound as $idx => $numbersFoundCol ) { // we need to make sure this is a column and not just a scalar or associative array $numbersFoundCol = is_array( $numbersFoundCol ) ? $numbersFoundCol : [ $numbersFoundCol ]; $numbersFoundCol = array_values( $numbersFoundCol ); // When the column isn't named, we index by the block name and column number if ( !is_string( $idx ) ) { $idx = $title . (count($numbersFound) > 1 ? " - (" . $idx + 1 . ")" : ""); } else { $idx = $title . ($idx ? ' - ' . Member::loggedIn()->language()->addToStack( $idx ) : ""); } Member::loggedIn()->language()->parseOutputForDisplay( $idx ); $returnData['headers'][] = $idx; $rowCount = max( $rowCount, count( $numbersFoundCol ) ); if ( $rowCount > count( $numbersFoundCol ) ) { $numbersFoundCol = array_merge( $numbersFoundCol, array_fill( 0, $rowCount - count( $numbersFoundCol ), "NULL" ) ); } $columns[] = $numbersFoundCol; } } // now we need to transpose the columns into rows for( $column = 0; $column < count( $columns ); $column++ ) { for ( $row = 0; $row < $rowCount; $row++ ) { $returnData['data'][$row] = $returnData['data'][$row] ?? []; $returnData['data'][$row][$column] = $columns[$column][$row] ?? "NULL"; } } return $returnData; } }