MediaWiki  master
ForeignResourceManager.php
Go to the documentation of this file.
1 <?php
23 
30  private $defaultAlgo = 'sha384';
31  private $hasErrors = false;
32  private $registryFile;
33  private $libDir;
34  private $tmpParentDir;
35  private $cacheDir;
40  private $infoPrinter;
45  private $errorPrinter;
50  private $verbosePrinter;
51  private $action;
53  private $registry;
54 
63  public function __construct(
65  $libDir,
66  callable $infoPrinter = null,
67  callable $errorPrinter = null,
68  callable $verbosePrinter = null
69  ) {
70  $this->registryFile = $registryFile;
71  $this->libDir = $libDir;
72  $this->infoPrinter = $infoPrinter ?? function ( $_ ) {
73  };
74  $this->errorPrinter = $errorPrinter ?? $this->infoPrinter;
75  $this->verbosePrinter = $verbosePrinter ?? function ( $_ ) {
76  };
77 
78  // Use a temporary directory under the destination directory instead
79  // of wfTempDir() because PHP's rename() does not work across file
80  // systems, and the user's /tmp and $IP may be on different filesystems.
81  $this->tmpParentDir = "{$this->libDir}/.foreign/tmp";
82 
83  $cacheHome = getenv( 'XDG_CACHE_HOME' ) ? realpath( getenv( 'XDG_CACHE_HOME' ) ) : false;
84  $this->cacheDir = $cacheHome ? "$cacheHome/mw-foreign" : "{$this->libDir}/.foreign/cache";
85  }
86 
93  public function run( $action, $module ) {
94  $actions = [ 'update', 'verify', 'make-sri' ];
95  if ( !in_array( $action, $actions ) ) {
96  $this->error( "Invalid action.\n\nMust be one of " . implode( ', ', $actions ) . '.' );
97  return false;
98  }
99  $this->action = $action;
100 
101  $this->registry = $this->parseBasicYaml( file_get_contents( $this->registryFile ) );
102  if ( $module === 'all' ) {
104  } elseif ( isset( $this->registry[ $module ] ) ) {
105  $modules = [ $module => $this->registry[ $module ] ];
106  } else {
107  $this->error( "Unknown module name.\n\nMust be one of:\n" .
108  wordwrap( implode( ', ', array_keys( $this->registry ) ), 80 ) .
109  '.'
110  );
111  return false;
112  }
113 
114  foreach ( $modules as $moduleName => $info ) {
115  $this->verbose( "\n### {$moduleName}\n\n" );
116  $destDir = "{$this->libDir}/$moduleName";
117 
118  if ( $this->action === 'update' ) {
119  $this->output( "... updating '{$moduleName}'\n" );
120  $this->verbose( "... emptying directory for $moduleName\n" );
121  wfRecursiveRemoveDir( $destDir );
122  } elseif ( $this->action === 'verify' ) {
123  $this->output( "... verifying '{$moduleName}'\n" );
124  } else {
125  $this->output( "... checking '{$moduleName}'\n" );
126  }
127 
128  $this->verbose( "... preparing {$this->tmpParentDir}\n" );
129  wfRecursiveRemoveDir( $this->tmpParentDir );
130  if ( !wfMkdirParents( $this->tmpParentDir ) ) {
131  throw new Exception( "Unable to create {$this->tmpParentDir}" );
132  }
133 
134  if ( !isset( $info['type'] ) ) {
135  throw new Exception( "Module '$moduleName' must have a 'type' key." );
136  }
137  switch ( $info['type'] ) {
138  case 'tar':
139  $this->handleTypeTar( $moduleName, $destDir, $info );
140  break;
141  case 'file':
142  $this->handleTypeFile( $moduleName, $destDir, $info );
143  break;
144  case 'multi-file':
145  $this->handleTypeMultiFile( $moduleName, $destDir, $info );
146  break;
147  default:
148  throw new Exception( "Unknown type '{$info['type']}' for '$moduleName'" );
149  }
150  }
151 
152  $this->output( "\nDone!\n" );
153  $this->cleanUp();
154  if ( $this->hasErrors ) {
155  // The verify mode should check all modules/files and fail after, not during.
156  return false;
157  }
158 
159  return true;
160  }
161 
162  private function cacheKey( $src, $integrity ) {
163  $key = basename( $src ) . '_' . substr( $integrity, -12 );
164  $key = preg_replace( '/[.\/+?=_-]+/', '_', $key );
165  return rtrim( $key, '_' );
166  }
167 
172  private function cacheGet( $key ) {
173  return AtEase::quietCall( 'file_get_contents', "{$this->cacheDir}/$key.data" );
174  }
175 
176  private function cacheSet( $key, $data ) {
177  wfMkdirParents( $this->cacheDir );
178  file_put_contents( "{$this->cacheDir}/$key.data", $data, LOCK_EX );
179  }
180 
181  private function fetch( $src, $integrity ) {
182  $key = $this->cacheKey( $src, $integrity );
183  $data = $this->cacheGet( $key );
184  if ( $data ) {
185  return $data;
186  }
187 
188  $req = MWHttpRequest::factory( $src, [ 'method' => 'GET', 'followRedirects' => false ] );
189  if ( !$req->execute()->isOK() ) {
190  throw new Exception( "Failed to download resource at {$src}" );
191  }
192  if ( $req->getStatus() !== 200 ) {
193  throw new Exception( "Unexpected HTTP {$req->getStatus()} response from {$src}" );
194  }
195  $data = $req->getContent();
196  $algo = $integrity === null ? $this->defaultAlgo : explode( '-', $integrity )[0];
197  $actualIntegrity = $algo . '-' . base64_encode( hash( $algo, $data, true ) );
198  if ( $integrity === $actualIntegrity ) {
199  $this->verbose( "... passed integrity check for {$src}\n" );
200  $this->cacheSet( $key, $data );
201  } elseif ( $this->action === 'make-sri' ) {
202  $this->output( "Integrity for {$src}\n\tintegrity: ${actualIntegrity}\n" );
203  } else {
204  throw new Exception( "Integrity check failed for {$src}\n" .
205  "\tExpected: {$integrity}\n" .
206  "\tActual: {$actualIntegrity}"
207  );
208  }
209  return $data;
210  }
211 
212  private function handleTypeFile( $moduleName, $destDir, array $info ) {
213  if ( !isset( $info['src'] ) ) {
214  throw new Exception( "Module '$moduleName' must have a 'src' key." );
215  }
216  $data = $this->fetch( $info['src'], $info['integrity'] ?? null );
217  $dest = $info['dest'] ?? basename( $info['src'] );
218  $path = "$destDir/$dest";
219  if ( $this->action === 'verify' && sha1_file( $path ) !== sha1( $data ) ) {
220  throw new Exception( "File for '$moduleName' is different." );
221  }
222  if ( $this->action === 'update' ) {
223  wfMkdirParents( $destDir );
224  file_put_contents( "$destDir/$dest", $data );
225  }
226  }
227 
228  private function handleTypeMultiFile( $moduleName, $destDir, array $info ) {
229  if ( !isset( $info['files'] ) ) {
230  throw new Exception( "Module '$moduleName' must have a 'files' key." );
231  }
232  foreach ( $info['files'] as $dest => $file ) {
233  if ( !isset( $file['src'] ) ) {
234  throw new Exception( "Module '$moduleName' file '$dest' must have a 'src' key." );
235  }
236  $data = $this->fetch( $file['src'], $file['integrity'] ?? null );
237  $path = "$destDir/$dest";
238  if ( $this->action === 'verify' && sha1_file( $path ) !== sha1( $data ) ) {
239  throw new Exception( "File '$dest' for '$moduleName' is different." );
240  } elseif ( $this->action === 'update' ) {
241  wfMkdirParents( $destDir );
242  file_put_contents( "$destDir/$dest", $data );
243  }
244  }
245  }
246 
247  private function handleTypeTar( $moduleName, $destDir, array $info ) {
248  $info += [ 'src' => null, 'integrity' => null, 'dest' => null ];
249  if ( $info['src'] === null ) {
250  throw new Exception( "Module '$moduleName' must have a 'src' key." );
251  }
252  // Download the resource to a temporary file and open it
253  $data = $this->fetch( $info['src'], $info['integrity' ] );
254  $tmpFile = "{$this->tmpParentDir}/$moduleName.tar";
255  $this->verbose( "... writing '$moduleName' src to $tmpFile\n" );
256  file_put_contents( $tmpFile, $data );
257  $p = new PharData( $tmpFile );
258  $tmpDir = "{$this->tmpParentDir}/$moduleName";
259  $p->extractTo( $tmpDir );
260  unset( $data, $p );
261 
262  if ( $info['dest'] === null ) {
263  // Default: Replace the entire directory
264  $toCopy = [ $tmpDir => $destDir ];
265  } else {
266  // Expand and normalise the 'dest' entries
267  $toCopy = [];
268  foreach ( $info['dest'] as $fromSubPath => $toSubPath ) {
269  // Use glob() to expand wildcards and check existence
270  $fromPaths = glob( "{$tmpDir}/{$fromSubPath}", GLOB_BRACE );
271  if ( !$fromPaths ) {
272  throw new Exception( "Path '$fromSubPath' of '$moduleName' not found." );
273  }
274  foreach ( $fromPaths as $fromPath ) {
275  $toCopy[$fromPath] = $toSubPath === null
276  ? "$destDir/" . basename( $fromPath )
277  : "$destDir/$toSubPath/" . basename( $fromPath );
278  }
279  }
280  }
281  foreach ( $toCopy as $from => $to ) {
282  if ( $this->action === 'verify' ) {
283  $this->verbose( "... verifying $to\n" );
284  if ( is_dir( $from ) ) {
285  $rii = new RecursiveIteratorIterator( new RecursiveDirectoryIterator(
286  $from,
287  RecursiveDirectoryIterator::SKIP_DOTS
288  ) );
290  foreach ( $rii as $file ) {
291  $remote = $file->getPathname();
292  $local = strtr( $remote, [ $from => $to ] );
293  if ( sha1_file( $remote ) !== sha1_file( $local ) ) {
294  $this->error( "File '$local' is different." );
295  $this->hasErrors = true;
296  }
297  }
298  } elseif ( sha1_file( $from ) !== sha1_file( $to ) ) {
299  $this->error( "File '$to' is different." );
300  $this->hasErrors = true;
301  }
302  } elseif ( $this->action === 'update' ) {
303  $this->verbose( "... moving $from to $to\n" );
304  wfMkdirParents( dirname( $to ) );
305  if ( !rename( $from, $to ) ) {
306  throw new Exception( "Could not move $from to $to." );
307  }
308  }
309  }
310  }
311 
312  private function verbose( $text ) {
313  ( $this->verbosePrinter )( $text );
314  }
315 
316  private function output( $text ) {
317  ( $this->infoPrinter )( $text );
318  }
319 
320  private function error( $text ) {
321  ( $this->errorPrinter )( $text );
322  }
323 
324  private function cleanUp() {
325  wfRecursiveRemoveDir( $this->tmpParentDir );
326 
327  // Prune the cache of files we don't recognise.
328  $knownKeys = [];
329  foreach ( $this->registry as $info ) {
330  if ( $info['type'] === 'file' || $info['type'] === 'tar' ) {
331  $knownKeys[] = $this->cacheKey( $info['src'], $info['integrity'] );
332  } elseif ( $info['type'] === 'multi-file' ) {
333  foreach ( $info['files'] as $file ) {
334  $knownKeys[] = $this->cacheKey( $file['src'], $file['integrity'] );
335  }
336  }
337  }
338  foreach ( glob( "{$this->cacheDir}/*" ) as $cacheFile ) {
339  if ( !in_array( basename( $cacheFile, '.data' ), $knownKeys ) ) {
340  unlink( $cacheFile );
341  }
342  }
343  }
344 
354  private function parseBasicYaml( $input ) {
355  $lines = explode( "\n", $input );
356  $root = [];
357  $stack = [ &$root ];
358  $prev = 0;
359  foreach ( $lines as $i => $text ) {
360  $line = $i + 1;
361  $trimmed = ltrim( $text, ' ' );
362  if ( $trimmed === '' || $trimmed[0] === '#' ) {
363  continue;
364  }
365  $indent = strlen( $text ) - strlen( $trimmed );
366  if ( $indent % 2 !== 0 ) {
367  throw new Exception( __METHOD__ . ": Odd indentation on line $line." );
368  }
369  $depth = $indent === 0 ? 0 : ( $indent / 2 );
370  if ( $depth < $prev ) {
371  // Close previous branches we can't re-enter
372  array_splice( $stack, $depth + 1 );
373  }
374  if ( !array_key_exists( $depth, $stack ) ) {
375  throw new Exception( __METHOD__ . ": Too much indentation on line $line." );
376  }
377  if ( strpos( $trimmed, ':' ) === false ) {
378  throw new Exception( __METHOD__ . ": Missing colon on line $line." );
379  }
380  $dest =& $stack[ $depth ];
381  if ( $dest === null ) {
382  // Promote from null to object
383  $dest = [];
384  }
385  list( $key, $val ) = explode( ':', $trimmed, 2 );
386  $val = ltrim( $val, ' ' );
387  if ( $val !== '' ) {
388  // Add string
389  $dest[ $key ] = $val;
390  } else {
391  // Add null (may become an object later)
392  $val = null;
393  $stack[] = &$val;
394  $dest[ $key ] = &$val;
395  }
396  $prev = $depth;
397  unset( $dest, $val );
398  }
399  return $root;
400  }
401 }
if(PHP_SAPI !='cli-server') if(!isset( $_SERVER['SCRIPT_FILENAME'])) $file
Item class for a filearchive table row.
Definition: router.php:42
static factory( $url, array $options=null, $caller=__METHOD__)
Generate a new request object.
wfRecursiveRemoveDir( $dir)
Remove a directory and all its content.
handleTypeMultiFile( $moduleName, $destDir, array $info)
__construct( $registryFile, $libDir, callable $infoPrinter=null, callable $errorPrinter=null, callable $verbosePrinter=null)
$modules
Manage foreign resources registered with ResourceLoader.
callable Closure $infoPrinter
-var callable(string):void
handleTypeTar( $moduleName, $destDir, array $info)
callable Closure $errorPrinter
-var callable(string):void
parseBasicYaml( $input)
Basic YAML parser.
$lines
Definition: router.php:61
wfMkdirParents( $dir, $mode=null, $caller=null)
Make directory, and make all parent directories if they don&#39;t exist.
callable Closure $verbosePrinter
-var callable(string):void
$line
Definition: mcc.php:119
handleTypeFile( $moduleName, $destDir, array $info)