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