Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
53.95% covered (warning)
53.95%
41 / 76
33.33% covered (danger)
33.33%
2 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileRevisionFromRemoteUrl
53.95% covered (warning)
53.95%
41 / 76
33.33% covered (danger)
33.33%
2 / 6
36.98
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 prepare
80.00% covered (warning)
80.00%
16 / 20
0.00% covered (danger)
0.00%
0 / 1
4.13
 validate
52.94% covered (warning)
52.94%
9 / 17
0.00% covered (danger)
0.00%
0 / 1
5.67
 commit
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
4.54
 createUploadLog
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 getWikiRevision
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace FileImporter\Operations;
4
5use FileImporter\Data\FileRevision;
6use FileImporter\Data\TextRevision;
7use FileImporter\Exceptions\HttpRequestException;
8use FileImporter\Exceptions\ImportException;
9use FileImporter\Exceptions\LocalizedImportException;
10use FileImporter\Interfaces\ImportOperation;
11use FileImporter\Services\Http\HttpRequestExecutor;
12use FileImporter\Services\UploadBase\UploadBaseFactory;
13use FileImporter\Services\UploadBase\ValidatingUploadBase;
14use FileImporter\Services\WikiRevisionFactory;
15use ManualLogEntry;
16use MediaWiki\FileBackend\FSFile\TempFSFileFactory;
17use MediaWiki\Permissions\RestrictionStore;
18use MediaWiki\Status\Status;
19use MediaWiki\Title\Title;
20use MediaWiki\User\User;
21use MediaWiki\User\UserIdentityLookup;
22use MWHttpRequest;
23use Psr\Log\LoggerInterface;
24use Psr\Log\NullLogger;
25use StatusValue;
26use UnexpectedValueException;
27use UploadBase;
28use UploadRevisionImporter;
29use WikiRevision;
30
31/**
32 * @license GPL-2.0-or-later
33 * @author Addshore
34 */
35class FileRevisionFromRemoteUrl implements ImportOperation {
36
37    private Title $plannedTitle;
38    private User $user;
39    private FileRevision $fileRevision;
40    private ?TextRevision $textRevision;
41    private UserIdentityLookup $userLookup;
42    private HttpRequestExecutor $httpRequestExecutor;
43    private WikiRevisionFactory $wikiRevisionFactory;
44    private UploadBaseFactory $uploadBaseFactory;
45    private UploadRevisionImporter $importer;
46    private RestrictionStore $restrictionStore;
47    private LoggerInterface $logger;
48
49    /** @var WikiRevision|null */
50    private $wikiRevision = null;
51    /** @var ValidatingUploadBase|null */
52    private $uploadBase = null;
53
54    /**
55     * @param Title $plannedTitle
56     * @param User $user user performing the import
57     * @param FileRevision $fileRevision
58     * @param TextRevision|null $textRevision
59     * @param UserIdentityLookup $userLookup
60     * @param HttpRequestExecutor $httpRequestExecutor
61     * @param WikiRevisionFactory $wikiRevisionFactory
62     * @param UploadBaseFactory $uploadBaseFactory
63     * @param UploadRevisionImporter $importer
64     * @param RestrictionStore $restrictionStore
65     * @param LoggerInterface|null $logger
66     */
67    public function __construct(
68        Title $plannedTitle,
69        User $user,
70        FileRevision $fileRevision,
71        ?TextRevision $textRevision,
72        UserIdentityLookup $userLookup,
73        HttpRequestExecutor $httpRequestExecutor,
74        WikiRevisionFactory $wikiRevisionFactory,
75        UploadBaseFactory $uploadBaseFactory,
76        UploadRevisionImporter $importer,
77        RestrictionStore $restrictionStore,
78        ?LoggerInterface $logger = null
79    ) {
80        $this->plannedTitle = $plannedTitle;
81        $this->user = $user;
82        $this->fileRevision = $fileRevision;
83        $this->textRevision = $textRevision;
84        $this->userLookup = $userLookup;
85        $this->httpRequestExecutor = $httpRequestExecutor;
86        $this->wikiRevisionFactory = $wikiRevisionFactory;
87        $this->uploadBaseFactory = $uploadBaseFactory;
88        $this->importer = $importer;
89        $this->restrictionStore = $restrictionStore;
90        $this->logger = $logger ?? new NullLogger();
91    }
92
93    /**
94     * Method to prepare an operation. This will not commit anything to any persistent storage.
95     * @return StatusValue isOK on success
96     */
97    public function prepare(): StatusValue {
98        $fileUrl = $this->fileRevision->getField( 'url' );
99        if ( !MWHttpRequest::isValidURI( $fileUrl ) ) {
100            // invalid URL detected
101            return StatusValue::newFatal( 'fileimporter-cantparseurl' );
102        }
103
104        $tmpFile = ( new TempFSFileFactory( wfTempDir() ) )->newTempFSFile( 'fileimporter_' );
105        $tmpFile->bind( $this );
106
107        try {
108            $this->httpRequestExecutor->executeAndSave( $fileUrl, $tmpFile->getPath() );
109        } catch ( HttpRequestException $ex ) {
110            if ( $ex->getCode() === 404 ) {
111                throw new LocalizedImportException( 'fileimporter-filemissinginrevision', $ex );
112            }
113            throw $ex;
114        }
115
116        $this->wikiRevision = $this->wikiRevisionFactory->newFromFileRevision(
117            $this->fileRevision,
118            $tmpFile->getPath()
119        );
120
121        $this->wikiRevision->setTitle( $this->plannedTitle );
122
123        $this->uploadBase = $this->uploadBaseFactory->newValidatingUploadBase(
124            $this->plannedTitle,
125            $this->wikiRevision->getFileSrc()
126        );
127
128        return StatusValue::newGood();
129    }
130
131    /**
132     * Method to validate prepared data that should be committed.
133     *
134     * @return StatusValue isOK on success
135     * @throws ImportException when critical validations fail
136     */
137    public function validate(): StatusValue {
138        $errorCode = $this->uploadBase->validateTitle();
139        if ( $errorCode !== UploadBase::OK ) {
140            $this->logger->error(
141                __METHOD__ . " failed to validate title, error code {$errorCode}",
142                [ 'fileRevision-getFields' => $this->fileRevision->getFields() ]
143            );
144            return StatusValue::newFatal( 'fileimporter-filenameerror-illegal' );
145        }
146
147        // Even administrators should not (accidentially) move a file to a protected file name
148        if ( $this->restrictionStore->isProtected( $this->plannedTitle ) ) {
149            return StatusValue::newFatal( 'fileimporter-filenameerror-protected' );
150        }
151
152        $fileValidationStatus = $this->uploadBase->validateFile();
153        if ( !$fileValidationStatus->isOK() ) {
154            $message = Status::wrap( $fileValidationStatus )->getMessage();
155            return StatusValue::newFatal( 'fileimporter-cantimportfileinvalid', $message );
156        }
157
158        return $this->uploadBase->validateUpload(
159            $this->user,
160            $this->textRevision
161        );
162    }
163
164    /**
165     * Commit this operation to persistent storage.
166     * @return StatusValue isOK on success
167     */
168    public function commit(): StatusValue {
169        $status = $this->importer->import( $this->wikiRevision );
170
171        if ( !$status->isGood() ) {
172            $this->logger->error( __METHOD__ . ' failed to commit.', [
173                'fileRevision-getFields' => $this->fileRevision->getFields(),
174                'status' => $status->__toString(),
175            ] );
176        }
177
178        /**
179         * Core only creates log entries for the latest revision. This results in a complete upload
180         * log only when the revisions are uploaded in chronological order, and all using the same
181         * file name.
182         *
183         * Here we are not only working in reverse chronological order, but also with archive file
184         * names that are all different. Core can't know if it needs to create historical log
185         * entries for these.
186         *
187         * According to {@see \LocalFile::publishTo} the {@see \StatusValue::$value} contains the
188         * archive file name.
189         */
190        if ( $status->value !== '' ) {
191            $this->createUploadLog();
192        }
193
194        return $status;
195    }
196
197    /**
198     * @see \LocalFile::recordUpload2
199     */
200    private function createUploadLog() {
201        $username = $this->wikiRevision->getUser();
202        $performer = $this->userLookup->getUserIdentityByName( $username );
203        if ( !$performer ) {
204            throw new UnexpectedValueException( "Unexpected non-normalized username \"$username\"" );
205        }
206
207        $logEntry = new ManualLogEntry( 'upload', 'upload' );
208        $logEntry->setTimestamp( $this->wikiRevision->getTimestamp() );
209        $logEntry->setPerformer( $performer );
210        $logEntry->setComment( $this->wikiRevision->getComment() );
211        $logEntry->setAssociatedRevId( $this->wikiRevision->getID() );
212        $logEntry->setTarget( $this->wikiRevision->getTitle() );
213        $logEntry->setParameters(
214            [
215                'img_sha1' => $this->wikiRevision->getSha1(),
216                'img_timestamp' => $this->wikiRevision->getTimestamp()
217            ]
218        );
219
220        $logId = $logEntry->insert();
221        $logEntry->publish( $logId );
222    }
223
224    /**
225     * @return WikiRevision|null
226     */
227    public function getWikiRevision() {
228        return $this->wikiRevision;
229    }
230
231}