Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 246
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Timeline
0.00% covered (danger)
0.00%
0 / 246
0.00% covered (danger)
0.00%
0 / 15
2756
0.00% covered (danger)
0.00%
0 / 1
 onParserFirstCallInit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onTagHook
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
30
 renderTimeline
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
90
 boxedCommand
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 addScript
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 createDirectory
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 eraseDirectory
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 determineFont
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 getBackend
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 throwRawException
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
2
 throwCompileException
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 extractMessage
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 fixMap
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
182
 recordShellout
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 recordError
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\Timeline;
4
5use DOMDocument;
6use FileBackend;
7use FSFileBackend;
8use MediaWiki\Hook\ParserFirstCallInitHook;
9use MediaWiki\Html\Html;
10use MediaWiki\Logger\LoggerFactory;
11use MediaWiki\MediaWikiServices;
12use MediaWiki\Parser\Sanitizer;
13use MediaWiki\WikiMap\WikiMap;
14use NullLockManager;
15use Parser;
16use Shellbox\Command\BoxedCommand;
17use Wikimedia\ScopedCallback;
18use Xml;
19
20class Timeline implements ParserFirstCallInitHook {
21
22    /**
23     * Bump when some change requires re-rendering all timelines
24     */
25    private const CACHE_VERSION = 2;
26
27    /** @var FileBackend|null instance cache */
28    private static $backend;
29
30    /**
31     * @param Parser $parser
32     */
33    public function onParserFirstCallInit( $parser ) {
34        $parser->setHook( 'timeline', [ self::class, 'onTagHook' ] );
35    }
36
37    /**
38     * Render timeline if necessary and display to users
39     *
40     * Based on the user's input, calculate a unique hash for the requested
41     * timeline. If it already exists in the FileBackend, serve it to the
42     * user. Otherwise call renderTimeline().
43     *
44     * Specially catch any TimelineExceptions and display a nice error for
45     * users and record it in stats.
46     *
47     * @param string|null $timelinesrc
48     * @param array $args
49     * @param Parser $parser
50     * @return string HTML
51     */
52    public static function onTagHook( ?string $timelinesrc, array $args, Parser $parser ) {
53        global $wgUploadPath;
54
55        $pOutput = $parser->getOutput();
56        $pOutput->addModuleStyles( [ 'ext.timeline.styles' ] );
57
58        $parser->addTrackingCategory( 'timeline-tracking-category' );
59
60        $createSvg = ( $args['method'] ?? null ) === 'svg2png';
61        $options = [
62            'createSvg' => $createSvg,
63            'font' => self::determineFont( $args['font'] ?? null ),
64        ];
65        if ( $timelinesrc === null ) {
66            $timelinesrc = '';
67        }
68
69        // Input for cache key
70        $cacheOptions = [
71            'code' => $timelinesrc,
72            'options' => $options,
73            'ExtVersion' => self::CACHE_VERSION,
74            // TODO: ploticus version? Given that it's
75            // dead upstream, unlikely to ever change.
76        ];
77        $hash = \Wikimedia\base_convert( sha1( serialize( $cacheOptions ) ), 16, 36, 31 );
78        $backend = self::getBackend();
79        // Storage destination path (excluding file extension)
80        // TODO: Implement $wgHashedUploadDirectory layout
81        $pathPrefix = 'mwstore://' . $backend->getName() . "/timeline-render/$hash";
82
83        $options += [
84            'pathPrefix' => $pathPrefix,
85        ];
86
87        $exists = $backend->fileExists( [ 'src' => "{$pathPrefix}.png" ] );
88        if ( !$exists ) {
89            try {
90                self::renderTimeline( $timelinesrc, $options );
91            } catch ( TimelineException $e ) {
92                // TODO: add error tracking category
93                self::recordError( $e );
94                return $e->getHtml();
95            }
96        }
97
98        $map = $backend->getFileContents( [ 'src' => "{$pathPrefix}.map" ] );
99
100        $map = str_replace( ' >', ' />', $map );
101        $map = Html::rawElement(
102            'map',
103            [ 'name' => "timeline_{$hash}" ],
104            $map
105        );
106        try {
107            $map = self::fixMap( $map );
108        } catch ( TimelineException $e ) {
109            // TODO: add error tracking category
110            self::recordError( $e );
111            return $e->getHtml();
112        }
113
114        $img = Html::element(
115            'img',
116            [
117                'usemap' => "#timeline_{$hash}",
118                'src' => "{$wgUploadPath}/timeline/{$hash}.png",
119            ]
120        );
121        return Html::rawElement(
122            'div',
123            [ 'class' => 'timeline-wrapper' ],
124            $map . $img
125        );
126    }
127
128    /**
129     * Render timeline and save to file backend
130     *
131     * A temporary factory directory is created to store things while they're
132     * being made. The renderTimeline.sh script is invoked via BoxedCommand
133     * which calls EasyTimeline.pl which calls ploticus.
134     *
135     * The rendered timeline is saved in the file backend using the provided
136     * 'pathPrefix'.
137     *
138     * @param string $timelinesrc
139     * @param array $options
140     * @throws TimelineException
141     */
142    private static function renderTimeline( string $timelinesrc, array $options ) {
143        global $wgArticlePath, $wgTmpDirectory, $wgTimelinePerlCommand,
144            $wgTimelinePloticusCommand, $wgShellboxShell, $wgTimelineRsvgCommand,
145            $wgPhpCli;
146
147        /* temporary working directory to use */
148        $fuzz = md5( (string)mt_rand() );
149        // @phan-suppress-next-line PhanTypeSuspiciousStringExpression
150        $factoryDirectory = $wgTmpDirectory . "/ET.$fuzz";
151        self::createDirectory( $factoryDirectory, 0700 );
152        // Make sure we clean up the directory at the end of this function
153        $teardown = new ScopedCallback( function () use ( $factoryDirectory ) {
154            self::eraseDirectory( $factoryDirectory );
155        } );
156
157        $env = [];
158        // Set font directory if configured
159        if ( $options['font']['dir'] !== false ) {
160            $env['GDFONTPATH'] = $options['font']['dir'];
161        }
162
163        $command = self::boxedCommand()
164            ->routeName( 'easytimeline' )
165            ->params(
166                $wgShellboxShell,
167                'scripts/renderTimeline.sh'
168            )
169            ->inputFileFromString( 'file', $timelinesrc )
170            // Save these as files
171            ->outputFileToFile( 'file.png', "$factoryDirectory/file.png" )
172            ->outputFileToFile( 'file.svg', "$factoryDirectory/file.svg" )
173            ->outputFileToFile( 'file.map', "$factoryDirectory/file.map" )
174            // Save error text in memory
175            ->outputFileToString( 'file.err' )
176            ->includeStderr()
177            ->environment( [
178                'ET_ARTICLEPATH' => $wgArticlePath,
179                'ET_FONTFILE' => $options['font']['file'],
180                'ET_PERL' => $wgTimelinePerlCommand,
181                'ET_PLOTICUS' => $wgTimelinePloticusCommand,
182                'ET_PHP' => $wgPhpCli,
183                'ET_RSVG' => $wgTimelineRsvgCommand,
184                'ET_SVG' => $options['createSvg'] ? 'yes' : 'no',
185            ] + $env );
186        self::addScript( $command, 'renderTimeline.sh' );
187        self::addScript( $command, 'EasyTimeline.pl' );
188        self::addScript( $command, 'extractSVGSize.php' );
189
190        $result = $command->execute();
191        self::recordShellout( 'render_timeline' );
192
193        if ( $result->wasReceived( 'file.err' ) ) {
194            $error = $result->getFileContents( 'file.err' );
195            self::throwRawException( $error );
196        }
197
198        $stdout = $result->getStdout();
199        if ( $result->getExitCode() != 0 || !strlen( $stdout ) ) {
200            self::throwCompileException( $stdout, $options );
201        }
202
203        // Copy the output files into storage...
204        $pathPrefix = $options['pathPrefix'];
205        $ops = [];
206        $backend = self::getBackend();
207        $backend->prepare( [ 'dir' => dirname( $pathPrefix ) ] );
208        // Save .map, .png, .svg files
209        foreach ( [ 'map', 'png', 'svg' ] as $ext ) {
210            if ( $result->wasReceived( "file.$ext" ) ) {
211                $ops[] = [
212                    'op' => 'store',
213                    'src' => "{$factoryDirectory}/file.{$ext}",
214                    'dst' => "{$pathPrefix}.{$ext}"
215                ];
216            }
217        }
218        if ( !$backend->doQuickOperations( $ops )->isOK() ) {
219            throw new TimelineException( 'timeline-error-storage' );
220        }
221    }
222
223    /**
224     * Return a BoxedCommand object
225     *
226     * @return BoxedCommand
227     */
228    private static function boxedCommand() {
229        return MediaWikiServices::getInstance()->getShellCommandFactory()
230            ->createBoxed( 'easytimeline' )
231            ->disableNetwork()
232            ->firejailDefaultSeccomp();
233    }
234
235    /**
236     * Add an input file from the scripts directory
237     *
238     * @param BoxedCommand $command
239     * @param string $script
240     */
241    private static function addScript( BoxedCommand $command, string $script ) {
242        $command->inputFileFromFile( "scripts/$script",
243            __DIR__ . "/../scripts/$script" );
244    }
245
246    /**
247     * Creates the specified local directory if it does not exist yet.
248     * Otherwise does nothing.
249     *
250     * @param string $path Local path to directory to be created.
251     * @param int|null $mode Chmod value of the new directory.
252     *
253     * @throws TimelineException if the directory does not exist and could not
254     *     be created.
255     */
256    private static function createDirectory( $path, $mode = null ) {
257        if ( !is_dir( $path ) ) {
258            $rc = wfMkdirParents( $path, $mode, __METHOD__ );
259            if ( !$rc ) {
260                throw new TimelineException( 'timeline-error-temp', [ $path ] );
261            }
262        }
263    }
264
265    /**
266     * Deletes a local directory with no subdirectories with all files in it.
267     *
268     * @param string $dir Local path to the directory that is to be deleted.
269     *
270     * @return bool true on success, false on error
271     */
272    private static function eraseDirectory( $dir ) {
273        if ( file_exists( $dir ) ) {
274            // @phan-suppress-next-line PhanPluginUseReturnValueInternalKnown
275            array_map( 'unlink', glob( "$dir/*", GLOB_NOSORT ) );
276            $rc = rmdir( $dir );
277            if ( !$rc ) {
278                wfDebug( __METHOD__ . ": Unable to remove directory $dir\n." );
279            }
280            return $rc;
281        }
282
283        /* Nothing to do */
284        return true;
285    }
286
287    /**
288     * Given a user's input of font, identify the font
289     * directory and font path that should be set
290     *
291     * @param ?string $input
292     * @return array with 'dir', 'file' keys. Note that 'dir' might be false.
293     */
294    private static function determineFont( $input ) {
295        global $wgTimelineFonts, $wgTimelineFontFile, $wgTimelineFontDirectory;
296        // Try the user-specified font, if invalid, use "default"
297        $fullPath = $wgTimelineFonts[$input] ?? $wgTimelineFonts['default'] ?? false;
298        if ( $fullPath !== false ) {
299            return [
300                'dir' => dirname( $fullPath ),
301                'file' => basename( $fullPath ),
302            ];
303        }
304        // Try using deprecated globals
305        return [
306            'dir' => $wgTimelineFontDirectory,
307            'file' => $wgTimelineFontFile,
308        ];
309    }
310
311    /**
312     * Files are saved to the file backend $wgTimelineFileBackend if set. Else
313     * default to FSFileBackend named 'timeline-backend'.
314     *
315     * @return FileBackend
316     */
317    private static function getBackend(): FileBackend {
318        global $wgTimelineFileBackend, $wgUploadDirectory;
319
320        if ( $wgTimelineFileBackend ) {
321            return MediaWikiServices::getInstance()->getFileBackendGroup()
322                ->get( $wgTimelineFileBackend );
323        }
324
325        if ( !self::$backend ) {
326            self::$backend = new FSFileBackend(
327                [
328                    'name' => 'timeline-backend',
329                    'wikiId' => WikiMap::getCurrentWikiId(),
330                    'lockManager' => new NullLockManager( [] ),
331                    'containerPaths' => [ 'timeline-render' => "{$wgUploadDirectory}/timeline" ],
332                    'fileMode' => 0777,
333                    'obResetFunc' => 'wfResetOutputBuffers',
334                    'streamMimeFunc' => [ 'StreamFile', 'contentTypeFromPath' ],
335                    'logger' => LoggerFactory::getInstance( 'timeline' ),
336                ]
337            );
338        }
339
340        return self::$backend;
341    }
342
343    /**
344     * Cleanup and throw errors from EasyTimeline.pl
345     *
346     * @param string $err
347     * @throws TimelineException
348     * @return never
349     */
350    private static function throwRawException( $err ) {
351        // Convert the error from poorly-sanitized HTML to plain text
352        $err = strtr( $err, [
353            '</p><p>' => "\n\n",
354            '<p>' => '',
355            '</p>' => '',
356            '<b>' => '',
357            '</b>' => '',
358            '<br>' => "\n"
359        ] );
360        $err = Sanitizer::decodeCharReferences( $err );
361
362        // Now convert back to HTML again
363        $encErr = nl2br( htmlspecialchars( $err ) );
364        $params = [ Html::rawElement(
365            'div',
366            [
367                'class' => [ 'error', 'timeline-error' ],
368                'lang' => 'en',
369                'dir' => 'ltr',
370            ],
371            $encErr
372        ) ];
373        throw new TimelineException( 'timeline-compilererr', $params );
374    }
375
376    /**
377     * Get error information from the output returned by scripts/renderTimeline.sh
378     * and throw a relevant error.
379     *
380     * @param string $stdout
381     * @param array $options
382     * @throws TimelineException
383     * @return never
384     */
385    private static function throwCompileException( $stdout, $options ) {
386        $extracted = self::extractMessage( $stdout );
387        if ( $extracted ) {
388            $message = $extracted[0];
389            $params = $extracted[1];
390        } else {
391            $message = 'timeline-compilererr';
392            $params = [];
393        }
394        // Add stdout (always in English) as a param, wrapped in <pre>
395        $params[] = Html::element(
396            'pre',
397            [ 'lang' => 'en', 'dir' => 'ltr' ],
398            $stdout
399        );
400        throw new TimelineException( $message, $params );
401    }
402
403    /**
404     * Parse the script return value and extract any mw-msg lines. Modify the
405     * text to remove the lines. Return the first mw-msg line as a Message
406     * object. If there was no mw-msg line, return null.
407     *
408     * @param string &$stdout
409     * @return array|null
410     */
411    private static function extractMessage( &$stdout ) {
412        $filteredStdout = '';
413        $messageParams = [];
414        foreach ( explode( "\n", $stdout ) as $line ) {
415            if ( preg_match( '/^mw-msg:\t/', $line ) ) {
416                if ( !$messageParams ) {
417                    $messageParams = array_slice( explode( "\t", $line ), 1 );
418                }
419            } else {
420                if ( $filteredStdout !== '' ) {
421                    $filteredStdout .= "\n";
422                }
423                $filteredStdout .= $line;
424            }
425        }
426        $stdout = $filteredStdout;
427        if ( $messageParams ) {
428            $messageName = array_shift( $messageParams );
429            // Used messages:
430            // - timeline-readerr
431            // - timeline-scripterr
432            // - timeline-perlnotexecutable
433            // - timeline-ploticusnotexecutable
434            // - timeline-compilererr
435            // - timeline-rsvg-error
436            return [ $messageName, $messageParams ];
437        } else {
438            return null;
439        }
440    }
441
442    /**
443     * Do a security check on the image map HTML
444     * @param string $html
445     * @return string HTML
446     */
447    private static function fixMap( $html ) {
448        global $wgUrlProtocols;
449        $doc = new DOMDocument( '1.0', 'UTF-8' );
450        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
451        $status = @$doc->loadXML( $html );
452        if ( !$status ) {
453            throw new TimelineException( 'timeline-invalidmap' );
454        }
455
456        $map = $doc->firstChild;
457        if ( strtolower( $map->nodeName ) !== 'map' ) {
458            throw new TimelineException( 'timeline-invalidmap' );
459        }
460        /** @phan-suppress-next-line PhanUndeclaredProperty */
461        $name = $map->attributes->getNamedItem( 'name' )->value;
462        $res = Xml::openElement( 'map', [ 'name' => $name ] );
463
464        $allowedAttribs = [ 'shape', 'coords', 'href', 'nohref', 'alt', 'tabindex', 'title' ];
465        foreach ( $map->childNodes as $node ) {
466            if ( strtolower( $node->nodeName ) !== 'area' ) {
467                continue;
468            }
469            $ok = true;
470            $attributes = [];
471            foreach ( $node->attributes as $name => $value ) {
472                $value = $value->value;
473                $lcName = strtolower( $name );
474                if ( !in_array( $lcName, $allowedAttribs ) ) {
475                    $ok = false;
476                    break;
477                }
478                if ( $lcName == 'href' && substr( $value, 0, 1 ) !== '/' ) {
479                    $ok = false;
480                    foreach ( $wgUrlProtocols as $protocol ) {
481                        if ( substr( $value, 0, strlen( $protocol ) ) == $protocol ) {
482                            $ok = true;
483                            break;
484                        }
485                    }
486                    if ( !$ok ) {
487                        break;
488                    }
489                }
490                $attributes[$name] = $value;
491            }
492            if ( !$ok ) {
493                $res .= "<!-- illegal element removed -->\n";
494                continue;
495            }
496
497            $res .= Xml::element( 'area', $attributes );
498        }
499        $res .= '</map>';
500
501        return $res;
502    }
503
504    /**
505     * Track how often we do each type of shellout in statsd
506     *
507     * @param string $type Type of shellout
508     */
509    private static function recordShellout( $type ) {
510        $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory();
511        $statsd->increment( "timeline_shell.$type" );
512    }
513
514    /**
515     * Track how often each error is received in statsd
516     *
517     * @param TimelineException $ex
518     */
519    private static function recordError( TimelineException $ex ) {
520        $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory();
521        $statsd->increment( "timeline_error.{$ex->getStatsdKey()}" );
522    }
523}