Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
25.00% covered (danger)
25.00%
1 / 4
CRAP
2.72% covered (danger)
2.72%
4 / 147
Timeline
0.00% covered (danger)
0.00%
0 / 1
25.00% covered (danger)
25.00%
1 / 4
1512.91
2.72% covered (danger)
2.72%
4 / 147
 onParserFirstCallInit
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 2
 renderTimeline
0.00% covered (danger)
0.00%
0 / 1
600
0.00% covered (danger)
0.00%
0 / 103
 hash
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
4 / 4
 fixMap
0.00% covered (danger)
0.00%
0 / 1
182
0.00% covered (danger)
0.00%
0 / 38
<?php
use MediaWiki\MediaWikiServices;
class Timeline {
    /**
     * @param Parser $parser
     * @return bool
     */
    public static function onParserFirstCallInit( $parser ) {
        $parser->setHook( 'timeline', 'Timeline::renderTimeline' );
        return true;
    }
    /**
     * Render timeline and save to file backend
     *
     * Files are saved to the file backend $wgTimelineFileBackend if set. Else
     * default to FSFileBackend named 'timeline-backend'.
     *
     * The rendered timeline is saved in the file backend using @see hash() and
     * will be reused if the hash match. You can invalidate the cache by
     * setting the global variable $wgRenderHashAppend (default: '').
     *
     * @param string $timelinesrc
     * @param array $args
     * @param Parser $parser
     * @param PPFrame $frame
     * @return string HTML
     * @throws Exception
     */
    public static function renderTimeline( $timelinesrc, array $args, $parser, $frame ) {
        global $wgUploadDirectory, $wgUploadPath, $wgArticlePath, $wgTmpDirectory;
        global $wgTimelineFileBackend, $wgTimelineEpochTimestamp, $wgTimelinePerlCommand, $wgTimelineFile;
        global $wgTimelineFontFile, $wgTimelineFontDirectory, $wgTimelinePloticusCommand;
        $parser->getOutput()->addModuleStyles( 'ext.timeline.styles' );
        $parser->addTrackingCategory( 'timeline-tracking-category' );
        $method = $args['method'] ?? 'ploticusOnly';
        $svg2png = ( $method == 'svg2png' );
        // Get the backend to store plot data and pngs
        if ( $wgTimelineFileBackend != '' ) {
            $backend = MediaWikiServices::getInstance()->getFileBackendGroup()
                ->get( $wgTimelineFileBackend );
        } else {
            $backend = new FSFileBackend(
                [
                    'name' => 'timeline-backend',
                    'wikiId' => wfWikiID(),
                    'lockManager' => new NullLockManager( [] ),
                    'containerPaths' => [ 'timeline-render' => "{$wgUploadDirectory}/timeline" ],
                    'fileMode' => 0777,
                    'obResetFunc' => 'wfResetOutputBuffers',
                    'streamMimeFunc' => [ 'StreamFile', 'contentTypeFromPath' ]
                ]
            );
        }
        $hash = self::hash( $timelinesrc, $args );
        // Storage destination path (excluding file extension)
        $pathPrefix = 'mwstore://' . $backend->getName() . "/timeline-render/$hash";
        $previouslyFailed = $backend->fileExists( [ 'src' => "{$pathPrefix}.err" ] );
        $previouslyRendered = $backend->fileExists( [ 'src' => "{$pathPrefix}.png" ] );
        if ( $previouslyRendered ) {
            $timestamp = $backend->getFileTimestamp( [ 'src' => "{$pathPrefix}.png" ] );
            $expired = ( $timestamp < $wgTimelineEpochTimestamp );
        } else {
            $expired = false;
        }
        // Create a new .map, .png (or .gif), and .err file as needed...
        if ( $expired || ( !$previouslyRendered && !$previouslyFailed ) ) {
            // @phan-suppress-next-line PhanTypeMismatchArgumentInternal Too aggressive inference
            if ( !is_dir( $wgTmpDirectory ) ) {
                // @phan-suppress-next-line PhanTypeMismatchArgumentInternal Too aggressive inference
                mkdir( $wgTmpDirectory, 0777 );
            }
            $tmpFiles = [];
            $tmpFile = TempFSFile::factory( 'timeline_' );
            if ( !$tmpFile ) {
                return "<div class=\"error timeline-error\">"
                    . wfMessage( 'timeline-error-temp' )->escaped()
                    . "</div>";
            }
            $tmpPath = $tmpFile->getPath();
            // store plot data to file
            file_put_contents( $tmpPath, $timelinesrc );
            // temp files to clean up
            foreach ( [ 'map', 'png', 'svg', 'err' ] as $ext ) {
                $fileCollect = new TempFSFile( "{$tmpPath}.{$ext}" );
                // clean this up
                $fileCollect->autocollect();
                $tmpFiles[] = $fileCollect;
            }
            // Get command for ploticus to read the user input and output an error,
            // map, and rendering (png or gif) file under the same dir as the temp file.
            $cmdline = wfEscapeShellArg( $wgTimelinePerlCommand, $wgTimelineFile )
                . ( $svg2png ? " -s " : "" )
                . " -i " . wfEscapeShellArg( $tmpPath )
                . " -m -P " . wfEscapeShellArg( $wgTimelinePloticusCommand )
                // @phan-suppress-next-line PhanTypeMismatchArgument Too aggressive inference
                . " -T " . wfEscapeShellArg( $wgTmpDirectory )
                . " -A " . wfEscapeShellArg( $wgArticlePath )
                . " -f " . wfEscapeShellArg( $wgTimelineFontFile );
            $env = [];
            if ( $wgTimelineFontDirectory !== false ) {
                $env['GDFONTPATH'] = $wgTimelineFontDirectory;
            }
            // Actually run the command...
            wfDebug( "Timeline cmd: $cmdline\n" );
            $retVal = null;
            $ret = wfShellExec( $cmdline, $retVal, $env );
            // If running in svg2png mode, create the PNG file from the SVG
            if ( $svg2png ) {
                // Read the default timeline image size from the DVG file
                $svgFilename = "{$tmpPath}.svg";
                Wikimedia\suppressWarnings();
                $svgHandle = fopen( $svgFilename, "r" );
                Wikimedia\restoreWarnings();
                if ( !$svgHandle ) {
                    throw new Exception( "Unable to open file $svgFilename for reading the timeline size" );
                }
                $svgWidth = '';
                $svgHeight = '';
                while ( !feof( $svgHandle ) ) {
                    $line = fgets( $svgHandle );
                    if ( preg_match( '/width="([0-9.]+)" height="([0-9.]+)"/', $line, $matches ) ) {
                        $svgWidth = $matches[1];
                        $svgHeight = $matches[2];
                        break;
                    }
                }
                fclose( $svgHandle );
                $svgHandler = new SvgHandler();
                wfDebug( "Rasterizing PNG timeline from SVG $svgFilename, size $svgWidth x $svgHeight\n" );
                $rasterizeResult = $svgHandler->rasterize(
                    $svgFilename,
                    "{$tmpPath}.png",
                    $svgWidth,
                    $svgHeight
                );
                if ( $rasterizeResult !== true ) {
                    return "<div class=\"error\" dir=\"ltr\">FAIL: " . $rasterizeResult->getHtmlMsg() . "</div>";
                }
            }
            // Copy the output files into storage...
            // @TODO: store error files in another container or not at all?
            $ops = [];
            $backend->prepare( [ 'dir' => dirname( $pathPrefix ) ] );
            foreach ( [ 'map', 'png', 'err' ] as $ext ) {
                if ( file_exists( "{$tmpPath}.{$ext}" ) ) {
                    $ops[] = [ 'op' => 'store', 'src' => "{$tmpPath}.{$ext}", 'dst' => "{$pathPrefix}.{$ext}" ];
                }
            }
            if ( !$backend->doQuickOperations( $ops )->isOK() ) {
                return "<div class=\"error timeline-error\">"
                    . wfMessage( 'timeline-error-storage' )->escaped()
                    . "</div>";
            }
            if ( $ret == "" || $retVal > 0 ) {
                return "<div class=\"error timeline-error\">"
                    . wfMessage( 'timeline-error-command' )->rawParams( htmlspecialchars( $cmdline ) )->escaped()
                    . "</div>";
            }
        }
        $err = $backend->getFileContents( [ 'src' => "{$pathPrefix}.err" ] );
        if ( $err != "" ) {
            // Convert the error from poorly-sanitized HTML to plain text
            $err = strtr( $err, [
                '</p><p>' => "\n\n",
                '<p>' => '',
                '</p>' => '',
                '<b>' => '',
                '</b>' => '',
                '<br>' => "\n"
            ] );
            $err = Sanitizer::decodeCharReferences( $err );
            // Now convert back to HTML again
            $encErr = nl2br( htmlspecialchars( $err ) );
            $txt = "<div class=\"error timeline-error\" dir=\"ltr\">$encErr</div>";
        } else {
            $map = $backend->getFileContents( [ 'src' => "{$pathPrefix}.map" ] );
            $map = str_replace( ' >', ' />', $map );
            $map = "<map name=\"timeline_" . htmlspecialchars( $hash ) . "\">{$map}</map>";
            $map = self::fixMap( $map );
            $url = "{$wgUploadPath}/timeline/{$hash}.png";
            $txt = "<div class=\"timeline-wrapper\">" . $map
                . "<img usemap=\"#timeline_" . htmlspecialchars( $hash )
                . "\" " . "src=\"" . htmlspecialchars( $url ) . "\">" . "</div>";
            if ( $expired ) {
                // Replacing an older file, we may need to purge the old one.
                global $wgUseCdn;
                if ( $wgUseCdn ) {
                    $u = new CdnCacheUpdate( [ $url ] );
                    $u->doUpdate();
                }
            }
        }
        return $txt;
    }
    /**
     * Generate a hash of the plot data
     *
     * $args must be checked, because the same source text may be used with
     * different arguments.
     *
     * Uses global $wgRenderHashAppend to salt / vary the hash. Will invalidate
     * the cache as a side effect though old files will be left in the file
     * backend.
     *
     * @param string $timelinesrc
     * @param array $args
     * @return string hash
     */
    public static function hash( $timelinesrc, array $args ) {
        global $wgRenderHashAppend;
        $hash = md5( $timelinesrc . implode( '', $args ) );
        if ( $wgRenderHashAppend != '' ) {
            $hash = md5( $hash . $wgRenderHashAppend );
        }
        return $hash;
    }
    /**
     * Do a security check on the image map HTML
     * @param string $html
     * @return string HTML
     */
    private static function fixMap( $html ) {
        global $wgUrlProtocols;
        $doc = new DOMDocument( '1.0', 'UTF-8' );
        Wikimedia\suppressWarnings();
        $status = $doc->loadXML( $html );
        Wikimedia\restoreWarnings();
        if ( !$status ) {
            return '<div class="error">' . wfMessage( 'timeline-invalidmap' )->escaped() . '</div>';
        }
        $map = $doc->firstChild;
        if ( strtolower( $map->nodeName ) !== 'map' ) {
            return '<div class="error">' . wfMessage( 'timeline-invalidmap' )->escaped() . '</div>';
        }
        /** @phan-suppress-next-line PhanUndeclaredProperty */
        $name = $map->attributes->getNamedItem( 'name' )->value;
        $res = Xml::openElement( 'map', [ 'name' => $name ] );
        $allowedAttribs = [ 'shape', 'coords', 'href', 'nohref', 'alt', 'tabindex', 'title' ];
        foreach ( $map->childNodes as $node ) {
            if ( strtolower( $node->nodeName ) !== 'area' ) {
                continue;
            }
            $ok = true;
            $attributes = [];
            foreach ( $node->attributes as $name => $value ) {
                $value = $value->value;
                $lcName = strtolower( $name );
                if ( !in_array( $lcName, $allowedAttribs ) ) {
                    $ok = false;
                    break;
                }
                if ( $lcName == 'href' && substr( $value, 0, 1 ) !== '/' ) {
                    $ok = false;
                    foreach ( $wgUrlProtocols as $protocol ) {
                        if ( substr( $value, 0, strlen( $protocol ) ) == $protocol ) {
                            $ok = true;
                            break;
                        }
                    }
                    if ( !$ok ) {
                        break;
                    }
                }
                $attributes[$name] = $value;
            }
            if ( !$ok ) {
                $res .= "<!-- illegal element removed -->\n";
                continue;
            }
            $res .= Xml::element( 'area', $attributes );
        }
        $res .= '</map>';
        return $res;
    }
}