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.
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.
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
19 * @ingroup Maintenance
22 require_once __DIR__
. '/../Maintenance.php';
25 * Manage foreign resources registered with ResourceLoader.
27 * @ingroup Maintenance
30 class ManageForeignResources
extends Maintenance
{
31 private $defaultAlgo = 'sha384';
32 private $tmpParentDir;
34 public function __construct() {
36 parent
::__construct();
37 $this->addDescription( <<<TEXT
38 Manage foreign resources registered with ResourceLoader.
40 This helps developers to download, verify and update local copies of upstream
41 libraries registered as ResourceLoader modules. See also foreign-resources.yaml.
43 For sources that don't publish an integrity hash, leave the value empty at
44 first, and run this script with --make-sri to compute the hashes.
46 This script runs in dry mode by default. Use --update to actually change, remove,
47 or add files to /resources/lib/.
50 $this->addArg( 'module', 'Name of a single module (Default: all)', false );
51 $this->addOption( 'update', ' resources/lib/ missing integrity metadata' );
52 $this->addOption( 'make-sri', 'Compute missing integrity metadata' );
53 $this->addOption( 'verbose', 'Be verbose' );
55 // Use a directory in $IP instead of wfTempDir() because
56 // PHP's rename() does not work across file systems.
57 $this->tmpParentDir
= "{$IP}/resources/tmp";
60 public function execute() {
62 $module = $this->getArg();
63 $makeSRI = $this->hasOption( 'make-sri' );
65 $registry = $this->parseBasicYaml(
66 file_get_contents( __DIR__
. '/foreign-resources.yaml' )
68 foreach ( $registry as $moduleName => $info ) {
69 if ( $module !== null && $moduleName !== $module ) {
72 $this->verbose( "\n### {$moduleName}\n\n" );
74 // Validate required keys
75 $info +
= [ 'src' => null, 'integrity' => null, 'dest' => null ];
76 if ( $info['src'] === null ) {
77 $this->fatalError( "Module '$moduleName' must have a 'src' key." );
79 $integrity = is_string( $info['integrity'] ) ?
$info['integrity'] : $makeSRI;
80 if ( $integrity === false ) {
81 $this->fatalError( "Module '$moduleName' must have an 'integrity' key." );
84 // Download the resource
85 $data = Http
::get( $info['src'], [ 'followRedirects' => false ] );
86 if ( $data === false ) {
87 $this->fatalError( "Failed to download resource for '$moduleName'." );
90 // Validate integrity metadata
91 $this->output( "... checking integrity of '{$moduleName}'\n" );
92 $algo = $integrity === true ?
$this->defaultAlgo
: explode( '-', $integrity )[0];
93 $actualIntegrity = $algo . '-' . base64_encode( hash( $algo, $data, true ) );
94 if ( $integrity === true ) {
95 $this->output( "Integrity for '{$moduleName}':\n\t${actualIntegrity}\n" );
97 } elseif ( $integrity !== $actualIntegrity ) {
98 $this->fatalError( "Integrity check failed for '{$moduleName}:\n" .
99 "Expected: {$integrity}\n" .
100 "Actual: {$actualIntegrity}"
104 // Determine destination
105 $destDir = "{$IP}/resources/lib/$moduleName";
106 $this->output( "... extracting files for '{$moduleName}'\n" );
107 $this->handleTypeTar( $moduleName, $data, $destDir, $info );
111 wfRecursiveRemoveDir( $this->tmpParentDir
);
112 $this->output( "\nDone!\n" );
115 private function handleTypeTar( $moduleName, $data, $destDir, array $info ) {
117 wfRecursiveRemoveDir( $this->tmpParentDir
);
118 if ( !wfMkdirParents( $this->tmpParentDir
) ) {
119 $this->fatalError( "Unable to create {$this->tmpParentDir}" );
122 // Write resource to temporary file and open it
123 $tmpFile = "{$this->tmpParentDir}/$moduleName.tar";
124 $this->verbose( "... writing '$moduleName' src to $tmpFile\n" );
125 file_put_contents( $tmpFile, $data );
126 $p = new PharData( $tmpFile );
127 $tmpDir = "{$this->tmpParentDir}/$moduleName";
128 $p->extractTo( $tmpDir );
131 if ( $info['dest'] === null ) {
132 // Replace the entire directory as-is
133 if ( !$this->hasOption( 'update' ) ) {
134 $this->output( "[dry run] Would replace /resources/lib/$moduleName\n" );
136 wfRecursiveRemoveDir( $destDir );
137 if ( !rename( $tmpDir, $destDir ) ) {
138 $this->fatalError( "Could not move $destDir to $tmpDir." );
144 // Create and/or empty the destination
145 if ( !$this->hasOption( 'update' ) ) {
146 $this->output( "... [dry run] would empty /resources/lib/$moduleName\n" );
148 wfRecursiveRemoveDir( $destDir );
149 wfMkdirParents( $destDir );
152 // Expand and normalise the 'dest' entries
154 foreach ( $info['dest'] as $fromSubPath => $toSubPath ) {
155 // Use glob() to expand wildcards and check existence
156 $fromPaths = glob( "{$tmpDir}/{$fromSubPath}", GLOB_BRACE
);
158 $this->fatalError( "Path '$fromSubPath' of '$moduleName' not found." );
160 foreach ( $fromPaths as $fromPath ) {
161 $toCopy[$fromPath] = $toSubPath === null
162 ?
"$destDir/" . basename( $fromPath )
163 : "$destDir/$toSubPath/" . basename( $fromPath );
166 foreach ( $toCopy as $from => $to ) {
167 if ( !$this->hasOption( 'update' ) ) {
168 $shortFrom = strtr( $from, [ "$tmpDir/" => '' ] );
169 $shortTo = strtr( $to, [ "$IP/" => '' ] );
170 $this->output( "... [dry run] would move $shortFrom to $shortTo\n" );
172 $this->verbose( "... moving $from to $to\n" );
173 wfMkdirParents( dirname( $to ) );
174 if ( !rename( $from, $to ) ) {
175 $this->fatalError( "Could not move $from to $to." );
181 private function verbose( $text ) {
182 if ( $this->hasOption( 'verbose' ) ) {
183 $this->output( $text );
190 * Supports only string or object values, and 2 spaces indentation.
192 * @todo Just ship symfony/yaml.
193 * @param string $input
196 private function parseBasicYaml( $input ) {
197 $lines = explode( "\n", $input );
201 foreach ( $lines as $i => $text ) {
203 $trimmed = ltrim( $text, ' ' );
204 if ( $trimmed === '' ||
$trimmed[0] === '#' ) {
207 $indent = strlen( $text ) - strlen( $trimmed );
208 if ( $indent %
2 !== 0 ) {
209 throw new Exception( __METHOD__
. ": Odd indentation on line $line." );
211 $depth = $indent === 0 ?
0 : ( $indent / 2 );
212 if ( $depth < $prev ) {
213 // Close previous branches we can't re-enter
214 array_splice( $stack, $depth +
1 );
216 if ( !array_key_exists( $depth, $stack ) ) {
217 throw new Exception( __METHOD__
. ": Too much indentation on line $line." );
219 if ( strpos( $trimmed, ':' ) === false ) {
220 throw new Exception( __METHOD__
. ": Missing colon on line $line." );
222 $dest =& $stack[ $depth ];
223 if ( $dest === null ) {
224 // Promote from null to object
227 list( $key, $val ) = explode( ':', $trimmed, 2 );
228 $val = ltrim( $val, ' ' );
231 $dest[ $key ] = $val;
233 // Add null (may become an object later)
236 $dest[ $key ] = &$val;
239 unset( $dest, $val );
245 $maintClass = ManageForeignResources
::class;
246 require_once RUN_MAINTENANCE_IF_MAIN
;