ForeignResourceManager.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. <?php
  2. /**
  3. * This program is free software; you can redistribute it and/or modify
  4. * it under the terms of the GNU General Public License as published by
  5. * the Free Software Foundation; either version 2 of the License, or
  6. * (at your option) any later version.
  7. *
  8. * This program is distributed in the hope that it will be useful,
  9. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. * GNU General Public License for more details.
  12. *
  13. * You should have received a copy of the GNU General Public License along
  14. * with this program; if not, write to the Free Software Foundation, Inc.,
  15. * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  16. * http://www.gnu.org/copyleft/gpl.html
  17. *
  18. * @file
  19. * @ingroup Maintenance
  20. */
  21. use Wikimedia\AtEase\AtEase;
  22. /**
  23. * Manage foreign resources registered with ResourceLoader.
  24. *
  25. * @since 1.32
  26. */
  27. class ForeignResourceManager {
  28. private $defaultAlgo = 'sha384';
  29. private $hasErrors = false;
  30. private $registryFile;
  31. private $libDir;
  32. private $tmpParentDir;
  33. private $cacheDir;
  34. private $infoPrinter;
  35. private $errorPrinter;
  36. private $verbosePrinter;
  37. private $action;
  38. private $registry;
  39. /**
  40. * @param string $registryFile Path to YAML file
  41. * @param string $libDir Path to a modules directory
  42. * @param callable|null $infoPrinter Callback for printing info about the run.
  43. * @param callable|null $errorPrinter Callback for printing errors from the run.
  44. * @param callable|null $verbosePrinter Callback for printing extra verbose
  45. * progress information from the run.
  46. */
  47. public function __construct(
  48. $registryFile,
  49. $libDir,
  50. callable $infoPrinter = null,
  51. callable $errorPrinter = null,
  52. callable $verbosePrinter = null
  53. ) {
  54. $this->registryFile = $registryFile;
  55. $this->libDir = $libDir;
  56. $this->infoPrinter = $infoPrinter ?? function () {
  57. };
  58. $this->errorPrinter = $errorPrinter ?? $this->infoPrinter;
  59. $this->verbosePrinter = $verbosePrinter ?? function () {
  60. };
  61. // Use a temporary directory under the destination directory instead
  62. // of wfTempDir() because PHP's rename() does not work across file
  63. // systems, and the user's /tmp and $IP may be on different filesystems.
  64. $this->tmpParentDir = "{$this->libDir}/.foreign/tmp";
  65. $cacheHome = getenv( 'XDG_CACHE_HOME' ) ? realpath( getenv( 'XDG_CACHE_HOME' ) ) : false;
  66. $this->cacheDir = $cacheHome ? "$cacheHome/mw-foreign" : "{$this->libDir}/.foreign/cache";
  67. }
  68. /**
  69. * @return bool
  70. * @throws Exception
  71. */
  72. public function run( $action, $module ) {
  73. $actions = [ 'update', 'verify', 'make-sri' ];
  74. if ( !in_array( $action, $actions ) ) {
  75. $this->error( "Invalid action.\n\nMust be one of " . implode( ', ', $actions ) . '.' );
  76. return false;
  77. }
  78. $this->action = $action;
  79. $this->registry = $this->parseBasicYaml( file_get_contents( $this->registryFile ) );
  80. if ( $module === 'all' ) {
  81. $modules = $this->registry;
  82. } elseif ( isset( $this->registry[ $module ] ) ) {
  83. $modules = [ $module => $this->registry[ $module ] ];
  84. } else {
  85. $this->error( "Unknown module name.\n\nMust be one of:\n" .
  86. wordwrap( implode( ', ', array_keys( $this->registry ) ), 80 ) .
  87. '.'
  88. );
  89. return false;
  90. }
  91. foreach ( $modules as $moduleName => $info ) {
  92. $this->verbose( "\n### {$moduleName}\n\n" );
  93. $destDir = "{$this->libDir}/$moduleName";
  94. if ( $this->action === 'update' ) {
  95. $this->output( "... updating '{$moduleName}'\n" );
  96. $this->verbose( "... emptying directory for $moduleName\n" );
  97. wfRecursiveRemoveDir( $destDir );
  98. } elseif ( $this->action === 'verify' ) {
  99. $this->output( "... verifying '{$moduleName}'\n" );
  100. } else {
  101. $this->output( "... checking '{$moduleName}'\n" );
  102. }
  103. $this->verbose( "... preparing {$this->tmpParentDir}\n" );
  104. wfRecursiveRemoveDir( $this->tmpParentDir );
  105. if ( !wfMkdirParents( $this->tmpParentDir ) ) {
  106. throw new Exception( "Unable to create {$this->tmpParentDir}" );
  107. }
  108. if ( !isset( $info['type'] ) ) {
  109. throw new Exception( "Module '$moduleName' must have a 'type' key." );
  110. }
  111. switch ( $info['type'] ) {
  112. case 'tar':
  113. $this->handleTypeTar( $moduleName, $destDir, $info );
  114. break;
  115. case 'file':
  116. $this->handleTypeFile( $moduleName, $destDir, $info );
  117. break;
  118. case 'multi-file':
  119. $this->handleTypeMultiFile( $moduleName, $destDir, $info );
  120. break;
  121. default:
  122. throw new Exception( "Unknown type '{$info['type']}' for '$moduleName'" );
  123. }
  124. }
  125. $this->output( "\nDone!\n" );
  126. $this->cleanUp();
  127. if ( $this->hasErrors ) {
  128. // The verify mode should check all modules/files and fail after, not during.
  129. return false;
  130. }
  131. return true;
  132. }
  133. private function cacheKey( $src, $integrity ) {
  134. $key = basename( $src ) . '_' . substr( $integrity, -12 );
  135. $key = preg_replace( '/[.\/+?=_-]+/', '_', $key );
  136. return rtrim( $key, '_' );
  137. }
  138. /** @return string|false */
  139. private function cacheGet( $key ) {
  140. return AtEase::quietCall( 'file_get_contents', "{$this->cacheDir}/$key.data" );
  141. }
  142. private function cacheSet( $key, $data ) {
  143. wfMkdirParents( $this->cacheDir );
  144. file_put_contents( "{$this->cacheDir}/$key.data", $data, LOCK_EX );
  145. }
  146. private function fetch( $src, $integrity ) {
  147. $key = $this->cacheKey( $src, $integrity );
  148. $data = $this->cacheGet( $key );
  149. if ( $data ) {
  150. return $data;
  151. }
  152. $req = MWHttpRequest::factory( $src, [ 'method' => 'GET', 'followRedirects' => false ] );
  153. if ( !$req->execute()->isOK() ) {
  154. throw new Exception( "Failed to download resource at {$src}" );
  155. }
  156. if ( $req->getStatus() !== 200 ) {
  157. throw new Exception( "Unexpected HTTP {$req->getStatus()} response from {$src}" );
  158. }
  159. $data = $req->getContent();
  160. $algo = $integrity === null ? $this->defaultAlgo : explode( '-', $integrity )[0];
  161. $actualIntegrity = $algo . '-' . base64_encode( hash( $algo, $data, true ) );
  162. if ( $integrity === $actualIntegrity ) {
  163. $this->verbose( "... passed integrity check for {$src}\n" );
  164. $this->cacheSet( $key, $data );
  165. } elseif ( $this->action === 'make-sri' ) {
  166. $this->output( "Integrity for {$src}\n\tintegrity: ${actualIntegrity}\n" );
  167. } else {
  168. throw new Exception( "Integrity check failed for {$src}\n" .
  169. "\tExpected: {$integrity}\n" .
  170. "\tActual: {$actualIntegrity}"
  171. );
  172. }
  173. return $data;
  174. }
  175. private function handleTypeFile( $moduleName, $destDir, array $info ) {
  176. if ( !isset( $info['src'] ) ) {
  177. throw new Exception( "Module '$moduleName' must have a 'src' key." );
  178. }
  179. $data = $this->fetch( $info['src'], $info['integrity'] ?? null );
  180. $dest = $info['dest'] ?? basename( $info['src'] );
  181. $path = "$destDir/$dest";
  182. if ( $this->action === 'verify' && sha1_file( $path ) !== sha1( $data ) ) {
  183. throw new Exception( "File for '$moduleName' is different." );
  184. }
  185. if ( $this->action === 'update' ) {
  186. wfMkdirParents( $destDir );
  187. file_put_contents( "$destDir/$dest", $data );
  188. }
  189. }
  190. private function handleTypeMultiFile( $moduleName, $destDir, array $info ) {
  191. if ( !isset( $info['files'] ) ) {
  192. throw new Exception( "Module '$moduleName' must have a 'files' key." );
  193. }
  194. foreach ( $info['files'] as $dest => $file ) {
  195. if ( !isset( $file['src'] ) ) {
  196. throw new Exception( "Module '$moduleName' file '$dest' must have a 'src' key." );
  197. }
  198. $data = $this->fetch( $file['src'], $file['integrity'] ?? null );
  199. $path = "$destDir/$dest";
  200. if ( $this->action === 'verify' && sha1_file( $path ) !== sha1( $data ) ) {
  201. throw new Exception( "File '$dest' for '$moduleName' is different." );
  202. } elseif ( $this->action === 'update' ) {
  203. wfMkdirParents( $destDir );
  204. file_put_contents( "$destDir/$dest", $data );
  205. }
  206. }
  207. }
  208. private function handleTypeTar( $moduleName, $destDir, array $info ) {
  209. $info += [ 'src' => null, 'integrity' => null, 'dest' => null ];
  210. if ( $info['src'] === null ) {
  211. throw new Exception( "Module '$moduleName' must have a 'src' key." );
  212. }
  213. // Download the resource to a temporary file and open it
  214. $data = $this->fetch( $info['src'], $info['integrity' ] );
  215. $tmpFile = "{$this->tmpParentDir}/$moduleName.tar";
  216. $this->verbose( "... writing '$moduleName' src to $tmpFile\n" );
  217. file_put_contents( $tmpFile, $data );
  218. $p = new PharData( $tmpFile );
  219. $tmpDir = "{$this->tmpParentDir}/$moduleName";
  220. $p->extractTo( $tmpDir );
  221. unset( $data, $p );
  222. if ( $info['dest'] === null ) {
  223. // Default: Replace the entire directory
  224. $toCopy = [ $tmpDir => $destDir ];
  225. } else {
  226. // Expand and normalise the 'dest' entries
  227. $toCopy = [];
  228. foreach ( $info['dest'] as $fromSubPath => $toSubPath ) {
  229. // Use glob() to expand wildcards and check existence
  230. $fromPaths = glob( "{$tmpDir}/{$fromSubPath}", GLOB_BRACE );
  231. if ( !$fromPaths ) {
  232. throw new Exception( "Path '$fromSubPath' of '$moduleName' not found." );
  233. }
  234. foreach ( $fromPaths as $fromPath ) {
  235. $toCopy[$fromPath] = $toSubPath === null
  236. ? "$destDir/" . basename( $fromPath )
  237. : "$destDir/$toSubPath/" . basename( $fromPath );
  238. }
  239. }
  240. }
  241. foreach ( $toCopy as $from => $to ) {
  242. if ( $this->action === 'verify' ) {
  243. $this->verbose( "... verifying $to\n" );
  244. if ( is_dir( $from ) ) {
  245. $rii = new RecursiveIteratorIterator( new RecursiveDirectoryIterator(
  246. $from,
  247. RecursiveDirectoryIterator::SKIP_DOTS
  248. ) );
  249. /** @var SplFileInfo $file */
  250. foreach ( $rii as $file ) {
  251. $remote = $file->getPathname();
  252. $local = strtr( $remote, [ $from => $to ] );
  253. if ( sha1_file( $remote ) !== sha1_file( $local ) ) {
  254. $this->error( "File '$local' is different." );
  255. $this->hasErrors = true;
  256. }
  257. }
  258. } elseif ( sha1_file( $from ) !== sha1_file( $to ) ) {
  259. $this->error( "File '$to' is different." );
  260. $this->hasErrors = true;
  261. }
  262. } elseif ( $this->action === 'update' ) {
  263. $this->verbose( "... moving $from to $to\n" );
  264. wfMkdirParents( dirname( $to ) );
  265. if ( !rename( $from, $to ) ) {
  266. throw new Exception( "Could not move $from to $to." );
  267. }
  268. }
  269. }
  270. }
  271. private function verbose( $text ) {
  272. ( $this->verbosePrinter )( $text );
  273. }
  274. private function output( $text ) {
  275. ( $this->infoPrinter )( $text );
  276. }
  277. private function error( $text ) {
  278. ( $this->errorPrinter )( $text );
  279. }
  280. private function cleanUp() {
  281. wfRecursiveRemoveDir( $this->tmpParentDir );
  282. // Prune the cache of files we don't recognise.
  283. $knownKeys = [];
  284. foreach ( $this->registry as $info ) {
  285. if ( $info['type'] === 'file' || $info['type'] === 'tar' ) {
  286. $knownKeys[] = $this->cacheKey( $info['src'], $info['integrity'] );
  287. } elseif ( $info['type'] === 'multi-file' ) {
  288. foreach ( $info['files'] as $file ) {
  289. $knownKeys[] = $this->cacheKey( $file['src'], $file['integrity'] );
  290. }
  291. }
  292. }
  293. foreach ( glob( "{$this->cacheDir}/*" ) as $cacheFile ) {
  294. if ( !in_array( basename( $cacheFile, '.data' ), $knownKeys ) ) {
  295. unlink( $cacheFile );
  296. }
  297. }
  298. }
  299. /**
  300. * Basic YAML parser.
  301. *
  302. * Supports only string or object values, and 2 spaces indentation.
  303. *
  304. * @todo Just ship symfony/yaml.
  305. * @param string $input
  306. * @return array
  307. */
  308. private function parseBasicYaml( $input ) {
  309. $lines = explode( "\n", $input );
  310. $root = [];
  311. $stack = [ &$root ];
  312. $prev = 0;
  313. foreach ( $lines as $i => $text ) {
  314. $line = $i + 1;
  315. $trimmed = ltrim( $text, ' ' );
  316. if ( $trimmed === '' || $trimmed[0] === '#' ) {
  317. continue;
  318. }
  319. $indent = strlen( $text ) - strlen( $trimmed );
  320. if ( $indent % 2 !== 0 ) {
  321. throw new Exception( __METHOD__ . ": Odd indentation on line $line." );
  322. }
  323. $depth = $indent === 0 ? 0 : ( $indent / 2 );
  324. if ( $depth < $prev ) {
  325. // Close previous branches we can't re-enter
  326. array_splice( $stack, $depth + 1 );
  327. }
  328. if ( !array_key_exists( $depth, $stack ) ) {
  329. throw new Exception( __METHOD__ . ": Too much indentation on line $line." );
  330. }
  331. if ( strpos( $trimmed, ':' ) === false ) {
  332. throw new Exception( __METHOD__ . ": Missing colon on line $line." );
  333. }
  334. $dest =& $stack[ $depth ];
  335. if ( $dest === null ) {
  336. // Promote from null to object
  337. $dest = [];
  338. }
  339. list( $key, $val ) = explode( ':', $trimmed, 2 );
  340. $val = ltrim( $val, ' ' );
  341. if ( $val !== '' ) {
  342. // Add string
  343. $dest[ $key ] = $val;
  344. } else {
  345. // Add null (may become an object later)
  346. $val = null;
  347. $stack[] = &$val;
  348. $dest[ $key ] = &$val;
  349. }
  350. $prev = $depth;
  351. unset( $dest, $val );
  352. }
  353. return $root;
  354. }
  355. }