Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.44% covered (warning)
66.44%
97 / 146
47.06% covered (danger)
47.06%
8 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
UploadFromUrl
66.90% covered (warning)
66.90%
97 / 145
47.06% covered (danger)
47.06%
8 / 17
145.35
0.00% covered (danger)
0.00%
0 / 1
 isAllowed
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isEnabled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isAllowedHost
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
9.01
 getCacheKey
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getCacheKeyFromRequest
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getAllowedHosts
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 isAllowedUrl
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getUrl
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 initialize
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 initializeFromRequest
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 isValidRequest
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getSourceType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchFile
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 canFetchFile
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
5.26
 makeTemporaryFile
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 saveTempFileChunk
41.67% covered (danger)
41.67%
5 / 12
0.00% covered (danger)
0.00%
0 / 1
2.79
 reallyFetchFile
78.26% covered (warning)
78.26%
36 / 46
0.00% covered (danger)
0.00%
0 / 1
11.03
1<?php
2/**
3 * Backend for uploading files from a HTTP resource.
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 * @ingroup Upload
8 */
9
10namespace MediaWiki\Upload;
11
12use MediaWiki\Context\RequestContext;
13use MediaWiki\HookContainer\HookRunner;
14use MediaWiki\MainConfigNames;
15use MediaWiki\MediaWikiServices;
16use MediaWiki\Permissions\Authority;
17use MediaWiki\Request\WebRequest;
18use MediaWiki\Status\Status;
19use MWHttpRequest;
20
21/**
22 * Implements uploading from a HTTP resource.
23 *
24 * @ingroup Upload
25 * @author Bryan Tong Minh
26 * @author Michael Dale
27 */
28class UploadFromUrl extends UploadBase {
29    /** @var string */
30    protected $mUrl;
31
32    /** @var resource|null|false */
33    protected $mTmpHandle;
34
35    /** @var array<string,bool> */
36    protected static $allowedUrls = [];
37
38    /**
39     * Checks if the user is allowed to use the upload-by-URL feature. If the
40     * user is not allowed, return the name of the user right as a string. If
41     * the user is allowed, have the parent do further permissions checking.
42     *
43     * @param Authority $performer
44     *
45     * @return bool|string
46     */
47    public static function isAllowed( Authority $performer ) {
48        if ( !$performer->isAllowed( 'upload_by_url' ) ) {
49            return 'upload_by_url';
50        }
51
52        return parent::isAllowed( $performer );
53    }
54
55    /**
56     * Checks if the upload from URL feature is enabled
57     * @return bool
58     */
59    public static function isEnabled() {
60        $allowCopyUploads = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::AllowCopyUploads );
61
62        return $allowCopyUploads && parent::isEnabled();
63    }
64
65    /**
66     * Checks whether the URL is for an allowed host
67     * The domains in the allowlist can include wildcard characters (*) in place
68     * of any of the domain levels, e.g. '*.flickr.com' or 'upload.*.gov.uk'.
69     *
70     * @param string $url
71     * @return bool
72     */
73    public static function isAllowedHost( $url ) {
74        $domains = self::getAllowedHosts();
75        if ( !count( $domains ) ) {
76            return true;
77        }
78        $parsedUrl = wfGetUrlUtils()->parse( $url );
79        if ( !$parsedUrl ) {
80            return false;
81        }
82        $valid = false;
83        foreach ( $domains as $domain ) {
84            // See if the domain for the upload matches this allowed domain
85            $domainPieces = explode( '.', $domain );
86            $uploadDomainPieces = explode( '.', $parsedUrl['host'] );
87            if ( count( $domainPieces ) === count( $uploadDomainPieces ) ) {
88                $valid = true;
89                // See if all the pieces match or not (excluding wildcards)
90                foreach ( $domainPieces as $index => $piece ) {
91                    if ( $piece !== '*' && $piece !== $uploadDomainPieces[$index] ) {
92                        $valid = false;
93                    }
94                }
95                if ( $valid ) {
96                    // We found a match, so quit comparing against the list
97                    break;
98                }
99            }
100            /* Non-wildcard test
101            if ( $parsedUrl['host'] === $domain ) {
102                $valid = true;
103                break;
104            }
105            */
106        }
107
108        return $valid;
109    }
110
111    /**
112     * Provides a caching key for an upload from url set of parameters
113     * Used to set the status of an async job in UploadFromUrlJob
114     * and retreive it in frontend clients like ApiUpload. Will return the
115     * empty string if not all parameters are present.
116     *
117     * @param array $params
118     * @return string
119     */
120    public static function getCacheKey( $params ) {
121        if ( !isset( $params['filename'] ) || !isset( $params['url'] ) ) {
122            return "";
123        } else {
124            // We use sha1 here to ensure we have a fixed-length string of printable
125            // characters. There is no cryptography involved, so we just need a
126            // relatively fast function.
127            return sha1( sprintf( "%s|||%s", $params['filename'], $params['url'] ) );
128        }
129    }
130
131    /**
132     * Get the caching key from a web request
133     * @param WebRequest &$request
134     *
135     * @return string
136     */
137    public static function getCacheKeyFromRequest( &$request ) {
138        $uploadCacheKey = $request->getText( 'wpCacheKey', $request->getText( 'key', '' ) );
139        if ( $uploadCacheKey !== '' ) {
140            return $uploadCacheKey;
141        }
142        $desiredDestName = $request->getText( 'wpDestFile' );
143        if ( !$desiredDestName ) {
144            $desiredDestName = $request->getText( 'wpUploadFileURL' );
145        }
146        return self::getCacheKey(
147            [
148                'filename' => $desiredDestName,
149                'url' => trim( $request->getVal( 'wpUploadFileURL' ) )
150            ]
151        );
152    }
153
154    /**
155     * @since 1.45 public
156     * @return string[]
157     */
158    public static function getAllowedHosts(): array {
159        $config = MediaWikiServices::getInstance()->getMainConfig();
160        $domains = $config->get( MainConfigNames::CopyUploadsDomains );
161
162        if ( $config->get( MainConfigNames::CopyUploadAllowOnWikiDomainConfig ) ) {
163            $page = wfMessage( 'copyupload-allowed-domains' )->inContentLanguage()->plain();
164
165            foreach ( explode( "\n", $page ) as $line ) {
166                // Strip comments
167                $line = preg_replace( "/^\\s*([^#]*)\\s*((.*)?)$/", "\\1", $line );
168                // Trim whitespace
169                $line = trim( $line );
170
171                if ( $line !== '' ) {
172                    $domains[] = $line;
173                }
174            }
175        }
176
177        return $domains;
178    }
179
180    /**
181     * Checks whether the URL is not allowed.
182     *
183     * @param string $url
184     * @return bool
185     */
186    public static function isAllowedUrl( $url ) {
187        if ( !isset( self::$allowedUrls[$url] ) ) {
188            $allowed = true;
189            ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )
190                ->onIsUploadAllowedFromUrl( $url, $allowed );
191            self::$allowedUrls[$url] = $allowed;
192        }
193
194        return self::$allowedUrls[$url];
195    }
196
197    /**
198     * Get the URL of the file to be uploaded
199     * @return string
200     */
201    public function getUrl() {
202        return $this->mUrl;
203    }
204
205    /**
206     * Entry point for API upload
207     *
208     * @param string $name
209     * @param string $url
210     */
211    public function initialize( $name, $url ) {
212        $this->mUrl = $url;
213
214        $tempPath = $this->makeTemporaryFile();
215        # File size and removeTempFile will be filled in later
216        $this->initializePathInfo( $name, $tempPath, 0, false );
217    }
218
219    /**
220     * Entry point for SpecialUpload
221     * @param WebRequest &$request
222     */
223    public function initializeFromRequest( &$request ) {
224        $desiredDestName = $request->getText( 'wpDestFile' );
225        if ( !$desiredDestName ) {
226            $desiredDestName = $request->getText( 'wpUploadFileURL' );
227        }
228        $this->initialize(
229            $desiredDestName,
230            trim( $request->getVal( 'wpUploadFileURL' ) )
231        );
232    }
233
234    /**
235     * @param WebRequest $request
236     * @return bool
237     */
238    public static function isValidRequest( $request ) {
239        $user = RequestContext::getMain()->getUser();
240
241        $url = $request->getVal( 'wpUploadFileURL' );
242
243        return $url
244            && MediaWikiServices::getInstance()
245                ->getPermissionManager()
246                ->userHasRight( $user, 'upload_by_url' );
247    }
248
249    /**
250     * @return string
251     */
252    public function getSourceType() {
253        return 'url';
254    }
255
256    /**
257     * Download the file
258     *
259     * @param array $httpOptions Array of options for MWHttpRequest.
260     *   This could be used to override the timeout on the http request.
261     * @return Status
262     */
263    public function fetchFile( $httpOptions = [] ) {
264        $status = $this->canFetchFile();
265        if ( !$status->isGood() ) {
266            return $status;
267        }
268        return $this->reallyFetchFile( $httpOptions );
269    }
270
271    /**
272     * verify we can actually download the file
273     *
274     * @return Status
275     */
276    public function canFetchFile() {
277        if ( !MWHttpRequest::isValidURI( $this->mUrl ) ) {
278            return Status::newFatal( 'http-invalid-url', $this->mUrl );
279        }
280
281        if ( !self::isAllowedHost( $this->mUrl ) ) {
282            return Status::newFatal( 'upload-copy-upload-invalid-domain' );
283        }
284        if ( !self::isAllowedUrl( $this->mUrl ) ) {
285            return Status::newFatal( 'upload-copy-upload-invalid-url' );
286        }
287        return Status::newGood();
288    }
289
290    /**
291     * Create a new temporary file in the URL subdirectory of wfTempDir().
292     *
293     * @return string Path to the file
294     */
295    protected function makeTemporaryFile() {
296        $tmpFile = MediaWikiServices::getInstance()->getTempFSFileFactory()
297            ->newTempFSFile( 'URL', 'urlupload_' );
298        $tmpFile->bind( $this );
299
300        return $tmpFile->getPath();
301    }
302
303    /**
304     * Callback: save a chunk of the result of a HTTP request to the temporary file
305     *
306     * @param mixed $req
307     * @param string $buffer
308     * @return int Number of bytes handled
309     */
310    public function saveTempFileChunk( $req, $buffer ) {
311        wfDebugLog( 'fileupload', 'Received chunk of ' . strlen( $buffer ) . ' bytes' );
312        $nbytes = fwrite( $this->mTmpHandle, $buffer );
313
314        if ( $nbytes == strlen( $buffer ) ) {
315            $this->mFileSize += $nbytes;
316        } else {
317            // Well... that's not good!
318            wfDebugLog(
319                'fileupload',
320                'Short write ' . $nbytes . '/' . strlen( $buffer ) .
321                ' bytes, aborting with ' . $this->mFileSize . ' uploaded so far'
322            );
323            fclose( $this->mTmpHandle );
324            $this->mTmpHandle = false;
325        }
326
327        return $nbytes;
328    }
329
330    /**
331     * Download the file, save it to the temporary file and update the file
332     * size and set $mRemoveTempFile to true.
333     *
334     * @param array $httpOptions Array of options for MWHttpRequest
335     * @return Status
336     */
337    protected function reallyFetchFile( $httpOptions = [] ) {
338        $copyUploadProxy = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::CopyUploadProxy );
339        $copyUploadTimeout = MediaWikiServices::getInstance()->getMainConfig()
340            ->get( MainConfigNames::CopyUploadTimeout );
341
342        // Note the temporary file should already be created by makeTemporaryFile()
343        $this->mTmpHandle = fopen( $this->mTempPath, 'wb' );
344        if ( !$this->mTmpHandle ) {
345            return Status::newFatal( 'tmp-create-error' );
346        }
347        wfDebugLog( 'fileupload', 'Temporary file created "' . $this->mTempPath . '"' );
348
349        $this->mRemoveTempFile = true;
350        $this->mFileSize = 0;
351
352        $options = $httpOptions + [ 'followRedirects' => false ];
353
354        if ( $copyUploadProxy !== false ) {
355            $options['proxy'] = $copyUploadProxy;
356        }
357
358        if ( $copyUploadTimeout && !isset( $options['timeout'] ) ) {
359            $options['timeout'] = $copyUploadTimeout;
360        }
361        wfDebugLog(
362            'fileupload',
363            'Starting download from "' . $this->mUrl . '" ' .
364            '<' . implode( ',', array_keys( array_filter( $options ) ) ) . '>'
365        );
366
367        // Manually follow any redirects up to the limit and reset the output file before each new request to prevent
368        // capturing the redirect response as part of the file.
369        $attemptsLeft = $options['maxRedirects'] ?? 5;
370        $targetUrl = $this->mUrl;
371        $requestFactory = MediaWikiServices::getInstance()->getHttpRequestFactory();
372        while ( $attemptsLeft > 0 ) {
373            $req = $requestFactory->create( $targetUrl, $options, __METHOD__ );
374            $req->setCallback( $this->saveTempFileChunk( ... ) );
375            $status = $req->execute();
376            if ( !$req->isRedirect() ) {
377                break;
378            }
379            $targetUrl = $req->getFinalUrl();
380            // Remove redirect response content from file.
381            ftruncate( $this->mTmpHandle, 0 );
382            rewind( $this->mTmpHandle );
383            $attemptsLeft--;
384        }
385
386        if ( $attemptsLeft == 0 ) {
387            return Status::newFatal( 'upload-too-many-redirects' );
388        }
389
390        if ( $this->mTmpHandle ) {
391            // File got written ok...
392            fclose( $this->mTmpHandle );
393            $this->mTmpHandle = null;
394        } else {
395            // We encountered a write error during the download...
396            return Status::newFatal( 'tmp-write-error' );
397        }
398
399        // @phan-suppress-next-line PhanPossiblyUndeclaredVariable Always set after loop
400        if ( $status->isOK() ) {
401            wfDebugLog( 'fileupload', 'Download by URL completed successfully.' );
402        } else {
403            // @phan-suppress-next-line PhanPossiblyUndeclaredVariable Always set after loop
404            wfDebugLog( 'fileupload', $status->getWikiText( false, false, 'en' ) );
405            wfDebugLog(
406                'fileupload',
407                // @phan-suppress-next-line PhanPossiblyUndeclaredVariable Always set after loop
408                'Download by URL completed with HTTP status ' . $req->getStatus()
409            );
410        }
411
412        // @phan-suppress-next-line PhanTypeMismatchReturnNullable,PhanPossiblyUndeclaredVariable Always set after loop
413        return $status;
414    }
415}
416
417/** @deprecated class alias since 1.46 */
418class_alias( UploadFromUrl::class, 'UploadFromUrl' );