MediaWiki REL1_40
ForeignResourceManager.php
Go to the documentation of this file.
1<?php
22
23use Composer\Spdx\SpdxLicenses;
24use Exception;
26use PharData;
27use RecursiveDirectoryIterator;
28use RecursiveIteratorIterator;
29use SplFileInfo;
30use Symfony\Component\Yaml\Yaml;
31
40 private $defaultAlgo = 'sha384';
41
43 private $hasErrors = false;
44
46 private $registryFile;
47
49 private $libDir;
50
52 private $tmpParentDir;
53
55 private $cacheDir;
56
61 private $infoPrinter;
62
67 private $errorPrinter;
72 private $verbosePrinter;
73
75 private $action;
76
78 private $registry;
79
88 public function __construct(
89 $registryFile,
90 $libDir,
91 callable $infoPrinter = null,
92 callable $errorPrinter = null,
93 callable $verbosePrinter = null
94 ) {
95 $this->registryFile = $registryFile;
96 $this->libDir = $libDir;
97 $this->infoPrinter = $infoPrinter ?? static function ( $_ ) {
98 };
99 $this->errorPrinter = $errorPrinter ?? $this->infoPrinter;
100 $this->verbosePrinter = $verbosePrinter ?? static function ( $_ ) {
101 };
102
103 // Use a temporary directory under the destination directory instead
104 // of wfTempDir() because PHP's rename() does not work across file
105 // systems, and the user's /tmp and $IP may be on different filesystems.
106 $this->tmpParentDir = "{$this->libDir}/.foreign/tmp";
107
108 // Support XDG_CACHE_HOME to speed up CI by avoiding repeated downloads.
109 $cacheHome = getenv( 'XDG_CACHE_HOME' ) ? realpath( getenv( 'XDG_CACHE_HOME' ) ) : false;
110 $this->cacheDir = $cacheHome ? "$cacheHome/mw-foreign" : "{$this->libDir}/.foreign/cache";
111 }
112
119 public function run( $action, $module ) {
120 $actions = [ 'update', 'verify', 'make-sri' ];
121 if ( !in_array( $action, $actions ) ) {
122 $this->error( "Invalid action.\n\nMust be one of " . implode( ', ', $actions ) . '.' );
123 return false;
124 }
125 $this->action = $action;
126
127 $this->registry = Yaml::parseFile( $this->registryFile );
128 if ( $module === 'all' ) {
129 $modules = $this->registry;
130 } elseif ( isset( $this->registry[$module] ) ) {
131 $modules = [ $module => $this->registry[$module] ];
132 } else {
133 $this->error( "Unknown module name.\n\nMust be one of:\n" .
134 wordwrap( implode( ', ', array_keys( $this->registry ) ), 80 ) .
135 '.'
136 );
137 return false;
138 }
139
140 foreach ( $modules as $moduleName => $info ) {
141 $this->verbose( "\n### {$moduleName}\n\n" );
142
143 if ( $this->action === 'update' ) {
144 $this->output( "... updating '{$moduleName}'\n" );
145 } elseif ( $this->action === 'verify' ) {
146 $this->output( "... verifying '{$moduleName}'\n" );
147 } else {
148 $this->output( "... checking '{$moduleName}'\n" );
149 }
150
151 // Do checks on yaml content (such as license existence, validity and type keys)
152 // before doing any potentially destructive actions (potentially deleting directories,
153 // depending on action.
154
155 if ( !isset( $info['type'] ) ) {
156 throw new Exception( "Module '$moduleName' must have a 'type' key." );
157 }
158
159 $this->validateLicense( $moduleName, $info );
160
161 $destDir = "{$this->libDir}/$moduleName";
162
163 if ( $this->action === 'update' ) {
164 $this->verbose( "... emptying directory for $moduleName\n" );
165 wfRecursiveRemoveDir( $destDir );
166 }
167
168 $this->verbose( "... preparing {$this->tmpParentDir}\n" );
169 wfRecursiveRemoveDir( $this->tmpParentDir );
170 if ( !wfMkdirParents( $this->tmpParentDir ) ) {
171 throw new Exception( "Unable to create {$this->tmpParentDir}" );
172 }
173
174 switch ( $info['type'] ) {
175 case 'tar':
176 $this->handleTypeTar( $moduleName, $destDir, $info );
177 break;
178 case 'file':
179 $this->handleTypeFile( $moduleName, $destDir, $info );
180 break;
181 case 'multi-file':
182 $this->handleTypeMultiFile( $moduleName, $destDir, $info );
183 break;
184 default:
185 throw new Exception( "Unknown type '{$info['type']}' for '$moduleName'" );
186 }
187 }
188
189 $this->cleanUp();
190 if ( $this->hasErrors ) {
191 // The "verify" action should check all modules and files and fail after, not during.
192 // We don't throw on the first issue so that developers enjoy access to all actionable
193 // information at once (given we can't have cascading errors).
194 // The "verify" action prints errors along the way and simply exits here.
195 return false;
196 }
197
198 return true;
199 }
200
207 private function cacheKey( $src, $integrity, $moduleName ) {
208 $key = $moduleName . '_' . hash( 'fnv132', $integrity ) . '_' . basename( $src );
209 $key = preg_replace( '/[.\/+?=_-]+/', '_', $key );
210 return rtrim( $key, '_' );
211 }
212
217 private function cacheGet( $key ) {
218 // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
219 return @file_get_contents( "{$this->cacheDir}/$key.data" );
220 }
221
226 private function cacheSet( $key, $data ) {
227 // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
228 @mkdir( $this->cacheDir, 0777, true );
229 file_put_contents( "{$this->cacheDir}/$key.data", $data, LOCK_EX );
230 }
231
238 private function fetch( string $src, $integrity, string $moduleName ) {
239 if ( $integrity !== null ) {
240 $key = $this->cacheKey( $src, $integrity, $moduleName );
241 $data = $this->cacheGet( $key );
242 if ( $data ) {
243 return $data;
244 }
245 }
246
247 $req = MediaWikiServices::getInstance()->getHttpRequestFactory()
248 ->create( $src, [ 'method' => 'GET', 'followRedirects' => false ], __METHOD__ );
249 if ( !$req->execute()->isOK() ) {
250 throw new Exception( "Failed to download resource at {$src}" );
251 }
252 if ( $req->getStatus() !== 200 ) {
253 throw new Exception( "Unexpected HTTP {$req->getStatus()} response from {$src}" );
254 }
255 $data = $req->getContent();
256 $algo = $integrity === null ? $this->defaultAlgo : explode( '-', $integrity )[0];
257 $actualIntegrity = $algo . '-' . base64_encode( hash( $algo, $data, true ) );
258 if ( $integrity === $actualIntegrity ) {
259 $this->verbose( "... passed integrity check for {$src}\n" );
260 $key = $this->cacheKey( $src, $actualIntegrity, $moduleName );
261 $this->cacheSet( $key, $data );
262 } elseif ( $this->action === 'make-sri' ) {
263 $this->output( "Integrity for {$src}\n\tintegrity: {$actualIntegrity}\n" );
264 } else {
265 $expectedIntegrity = $integrity ?? 'null';
266 throw new Exception( "Integrity check failed for {$src}\n" .
267 "\tExpected: {$expectedIntegrity}\n" .
268 "\tActual: {$actualIntegrity}"
269 );
270 }
271 return $data;
272 }
273
279 private function handleTypeFile( $moduleName, $destDir, array $info ) {
280 if ( !isset( $info['src'] ) ) {
281 throw new Exception( "Module '$moduleName' must have a 'src' key." );
282 }
283 $data = $this->fetch( $info['src'], $info['integrity'] ?? null, $moduleName );
284 $dest = $info['dest'] ?? basename( $info['src'] );
285 $path = "$destDir/$dest";
286 if ( $this->action === 'verify' && sha1_file( $path ) !== sha1( $data ) ) {
287 $this->error( "File for '$moduleName' is different.\n" );
288 }
289 if ( $this->action === 'update' ) {
290 wfMkdirParents( $destDir );
291 file_put_contents( "$destDir/$dest", $data );
292 }
293 }
294
300 private function handleTypeMultiFile( $moduleName, $destDir, array $info ) {
301 if ( !isset( $info['files'] ) ) {
302 throw new Exception( "Module '$moduleName' must have a 'files' key." );
303 }
304 foreach ( $info['files'] as $dest => $file ) {
305 if ( !isset( $file['src'] ) ) {
306 throw new Exception( "Module '$moduleName' file '$dest' must have a 'src' key." );
307 }
308 $data = $this->fetch( $file['src'], $file['integrity'] ?? null, $moduleName );
309 $path = "$destDir/$dest";
310 if ( $this->action === 'verify' && sha1_file( $path ) !== sha1( $data ) ) {
311 $this->error( "File '$dest' for '$moduleName' is different.\n" );
312 } elseif ( $this->action === 'update' ) {
313 wfMkdirParents( $destDir );
314 file_put_contents( "$destDir/$dest", $data );
315 }
316 }
317 }
318
324 private function handleTypeTar( $moduleName, $destDir, array $info ) {
325 $info += [ 'src' => null, 'integrity' => null, 'dest' => null ];
326 if ( $info['src'] === null ) {
327 throw new Exception( "Module '$moduleName' must have a 'src' key." );
328 }
329 // Download the resource to a temporary file and open it
330 $data = $this->fetch( $info['src'], $info['integrity'], $moduleName );
331 $tmpFile = "{$this->tmpParentDir}/$moduleName.tar";
332 $this->verbose( "... writing '$moduleName' src to $tmpFile\n" );
333 file_put_contents( $tmpFile, $data );
334 $p = new PharData( $tmpFile );
335 $tmpDir = "{$this->tmpParentDir}/$moduleName";
336 $p->extractTo( $tmpDir );
337 unset( $data, $p );
338
339 if ( $info['dest'] === null ) {
340 // Default: Replace the entire directory
341 $toCopy = [ $tmpDir => $destDir ];
342 } else {
343 // Expand and normalise the 'dest' entries
344 $toCopy = [];
345 foreach ( $info['dest'] as $fromSubPath => $toSubPath ) {
346 // Use glob() to expand wildcards and check existence
347 $fromPaths = glob( "{$tmpDir}/{$fromSubPath}", GLOB_BRACE );
348 if ( !$fromPaths ) {
349 throw new Exception( "Path '$fromSubPath' of '$moduleName' not found." );
350 }
351 foreach ( $fromPaths as $fromPath ) {
352 $toCopy[$fromPath] = $toSubPath === null
353 ? "$destDir/" . basename( $fromPath )
354 : "$destDir/$toSubPath/" . basename( $fromPath );
355 }
356 }
357 }
358 foreach ( $toCopy as $from => $to ) {
359 if ( $this->action === 'verify' ) {
360 $this->verbose( "... verifying $to\n" );
361 if ( is_dir( $from ) ) {
362 $rii = new RecursiveIteratorIterator( new RecursiveDirectoryIterator(
363 $from,
364 RecursiveDirectoryIterator::SKIP_DOTS
365 ) );
367 foreach ( $rii as $file ) {
368 $remote = $file->getPathname();
369 $local = strtr( $remote, [ $from => $to ] );
370 if ( sha1_file( $remote ) !== sha1_file( $local ) ) {
371 $this->error( "File '$local' is different.\n" );
372 }
373 }
374 } elseif ( sha1_file( $from ) !== sha1_file( $to ) ) {
375 $this->error( "File '$to' is different.\n" );
376 }
377 } elseif ( $this->action === 'update' ) {
378 $this->verbose( "... moving $from to $to\n" );
379 wfMkdirParents( dirname( $to ) );
380 if ( !rename( $from, $to ) ) {
381 throw new Exception( "Could not move $from to $to." );
382 }
383 }
384 }
385 }
386
390 private function verbose( $text ) {
391 ( $this->verbosePrinter )( $text );
392 }
393
397 private function output( $text ) {
398 ( $this->infoPrinter )( $text );
399 }
400
404 private function error( $text ) {
405 $this->hasErrors = true;
406 ( $this->errorPrinter )( $text );
407 }
408
409 private function cleanUp() {
410 wfRecursiveRemoveDir( $this->tmpParentDir );
411
412 // Prune the cache of files we don't recognise.
413 $knownKeys = [];
414 foreach ( $this->registry as $module => $info ) {
415 if ( $info['type'] === 'file' || $info['type'] === 'tar' ) {
416 $knownKeys[] = $this->cacheKey( $info['src'], $info['integrity'], $module );
417 } elseif ( $info['type'] === 'multi-file' ) {
418 foreach ( $info['files'] as $file ) {
419 $knownKeys[] = $this->cacheKey( $file['src'], $file['integrity'], $module );
420 }
421 }
422 }
423 foreach ( glob( "{$this->cacheDir}/*" ) as $cacheFile ) {
424 if ( !in_array( basename( $cacheFile, '.data' ), $knownKeys ) ) {
425 unlink( $cacheFile );
426 }
427 }
428 }
429
434 private function validateLicense( $moduleName, $info ) {
435 if ( !isset( $info['license'] ) || !is_string( $info['license'] ) ) {
436 throw new Exception( "Module '$moduleName' needs a valid SPDX license; no license is currently present" );
437 }
438 $licenses = new SpdxLicenses();
439 if ( !$licenses->validate( $info['license'] ) ) {
440 $this->error(
441 "Module '$moduleName' has an invalid SPDX license identifier '{$info['license']}', "
442 . "see <https://spdx.org/licenses/>.\n"
443 );
444 }
445 }
446}
447
449class_alias( ForeignResourceManager::class, 'ForeignResourceManager' );
wfRecursiveRemoveDir( $dir)
Remove a directory and all its content.
wfMkdirParents( $dir, $mode=null, $caller=null)
Make directory, and make all parent directories if they don't exist.
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
Manage foreign resources registered with ResourceLoader.
__construct( $registryFile, $libDir, callable $infoPrinter=null, callable $errorPrinter=null, callable $verbosePrinter=null)
if(PHP_SAPI !='cli-server') if(!isset( $_SERVER['SCRIPT_FILENAME'])) $file
Item class for a filearchive table row.
Definition router.php:42