Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 246 |
|
0.00% |
0 / 15 |
CRAP | |
0.00% |
0 / 1 |
Timeline | |
0.00% |
0 / 246 |
|
0.00% |
0 / 15 |
2756 | |
0.00% |
0 / 1 |
onParserFirstCallInit | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
onTagHook | |
0.00% |
0 / 53 |
|
0.00% |
0 / 1 |
30 | |||
renderTimeline | |
0.00% |
0 / 59 |
|
0.00% |
0 / 1 |
90 | |||
boxedCommand | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
addScript | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
createDirectory | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
eraseDirectory | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
determineFont | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
getBackend | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
12 | |||
throwRawException | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
2 | |||
throwCompileException | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
6 | |||
extractMessage | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
42 | |||
fixMap | |
0.00% |
0 / 37 |
|
0.00% |
0 / 1 |
182 | |||
recordShellout | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
recordError | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\Timeline; |
4 | |
5 | use DOMDocument; |
6 | use FileBackend; |
7 | use FSFileBackend; |
8 | use MediaWiki\Hook\ParserFirstCallInitHook; |
9 | use MediaWiki\Html\Html; |
10 | use MediaWiki\Logger\LoggerFactory; |
11 | use MediaWiki\MediaWikiServices; |
12 | use MediaWiki\Parser\Sanitizer; |
13 | use MediaWiki\WikiMap\WikiMap; |
14 | use NullLockManager; |
15 | use Parser; |
16 | use Shellbox\Command\BoxedCommand; |
17 | use Wikimedia\ScopedCallback; |
18 | use Xml; |
19 | |
20 | class 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 | } |