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