Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.35% covered (warning)
89.35%
151 / 169
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Importer
89.35% covered (warning)
89.35%
151 / 169
50.00% covered (danger)
50.00%
5 / 10
25.76
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 import
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
1 / 1
1
 buildImportOperations
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
3
 prepareImportOperations
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
3.19
 validateImportOperations
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
9
 commitImportOperations
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
3.19
 validateFileInfoText
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getPageFromImportPlan
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
2.31
 createPostImportNullRevision
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
2.04
 createPostImportEdit
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
2.12
1<?php
2
3namespace FileImporter\Services;
4
5use FileImporter\Data\ImportDetails;
6use FileImporter\Data\ImportOperations;
7use FileImporter\Data\ImportPlan;
8use FileImporter\Exceptions\AbuseFilterWarningsException;
9use FileImporter\Exceptions\ImportException;
10use FileImporter\Exceptions\LocalizedImportException;
11use FileImporter\Operations\FileRevisionFromRemoteUrl;
12use FileImporter\Operations\TextRevisionFromTextRevision;
13use FileImporter\Services\Http\HttpRequestExecutor;
14use FileImporter\Services\UploadBase\UploadBaseFactory;
15use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
16use MediaWiki\Api\IApiMessage;
17use MediaWiki\Content\WikitextContent;
18use MediaWiki\MediaWikiServices;
19use MediaWiki\Page\WikiPageFactory;
20use MediaWiki\Permissions\Authority;
21use MediaWiki\Permissions\RestrictionStore;
22use MediaWiki\Status\Status;
23use MediaWiki\Title\Title;
24use MediaWiki\User\User;
25use MediaWiki\User\UserIdentity;
26use MediaWiki\User\UserIdentityLookup;
27use OldRevisionImporter;
28use Psr\Log\LoggerInterface;
29use Psr\Log\NullLogger;
30use RuntimeException;
31use StatusValue;
32use UploadRevisionImporter;
33use Wikimedia\Message\MessageSpecifier;
34use Wikimedia\Rdbms\IDBAccessObject;
35use Wikimedia\Stats\NullStatsdDataFactory;
36use WikiPage;
37
38/**
39 * Performs an import of a file to the local wiki based on an ImportPlan object for a given User.
40 *
41 * @license GPL-2.0-or-later
42 * @author Addshore
43 */
44class Importer {
45
46    private const ERROR_NO_NEW_PAGE = 'noPageCreated';
47
48    private WikiPageFactory $wikiPageFactory;
49    private WikiRevisionFactory $wikiRevisionFactory;
50    private NullRevisionCreator $nullRevisionCreator;
51    private UserIdentityLookup $userLookup;
52    private HttpRequestExecutor $httpRequestExecutor;
53    private UploadBaseFactory $uploadBaseFactory;
54    private OldRevisionImporter $oldRevisionImporter;
55    private UploadRevisionImporter $uploadRevisionImporter;
56    private FileTextRevisionValidator $textRevisionValidator;
57    private RestrictionStore $restrictionStore;
58    private LoggerInterface $logger;
59    private StatsdDataFactoryInterface $stats;
60
61    public function __construct(
62        WikiPageFactory $wikiPageFactory,
63        WikiRevisionFactory $wikiRevisionFactory,
64        NullRevisionCreator $nullRevisionCreator,
65        UserIdentityLookup $userLookup,
66        HttpRequestExecutor $httpRequestExecutor,
67        UploadBaseFactory $uploadBaseFactory,
68        OldRevisionImporter $oldRevisionImporter,
69        UploadRevisionImporter $uploadRevisionImporter,
70        FileTextRevisionValidator $textRevisionValidator,
71        RestrictionStore $restrictionStore,
72        ?LoggerInterface $logger = null,
73        ?StatsdDataFactoryInterface $statsdDataFactory = null
74    ) {
75        $this->wikiPageFactory = $wikiPageFactory;
76        $this->wikiRevisionFactory = $wikiRevisionFactory;
77        $this->nullRevisionCreator = $nullRevisionCreator;
78        $this->userLookup = $userLookup;
79        $this->httpRequestExecutor = $httpRequestExecutor;
80        $this->uploadBaseFactory = $uploadBaseFactory;
81        $this->oldRevisionImporter = $oldRevisionImporter;
82        $this->uploadRevisionImporter = $uploadRevisionImporter;
83        $this->textRevisionValidator = $textRevisionValidator;
84        $this->restrictionStore = $restrictionStore;
85        $this->logger = $logger ?? new NullLogger();
86        $this->stats = $statsdDataFactory ?? new NullStatsdDataFactory();
87    }
88
89    /**
90     * @param User $user user to use for the import
91     * @param ImportPlan $importPlan A valid ImportPlan object.
92     *
93     * @throws ImportException|RuntimeException
94     */
95    public function import( User $user, ImportPlan $importPlan ) {
96        $this->wikiRevisionFactory->setInterWikiPrefix( $importPlan->getInterWikiPrefix() );
97
98        $importStart = microtime( true );
99        $this->logger->info( __METHOD__ . ' started' );
100
101        $validationStatus = $this->validateFileInfoText(
102            $user,
103            $importPlan
104        );
105
106        // TODO the type of ImportOperation created should be decided somewhere
107
108        $operationBuildingStart = microtime( true );
109        $importOperations = $this->buildImportOperations(
110            $user,
111            $importPlan->getTitle(),
112            $importPlan->getDetails()
113        );
114        $this->stats->timing(
115            'FileImporter.import.timing.buildOperations',
116            ( microtime( true ) - $operationBuildingStart ) * 1000
117        );
118
119        $operationPrepareStart = microtime( true );
120        $this->prepareImportOperations( $importOperations );
121        $this->stats->timing(
122            'FileImporter.import.timing.prepareOperations',
123            ( microtime( true ) - $operationPrepareStart ) * 1000
124        );
125
126        $operationValidateStart = microtime( true );
127        $validationStatus->merge( $importOperations->validate() );
128        $this->validateImportOperations( $validationStatus, $importPlan );
129        $this->stats->timing(
130            'FileImporter.import.timing.validateOperations',
131            ( microtime( true ) - $operationValidateStart ) * 1000
132        );
133
134        $operationCommitStart = microtime( true );
135        $this->commitImportOperations( $importOperations );
136        $this->stats->timing(
137            'FileImporter.import.timing.commitOperations',
138            ( microtime( true ) - $operationCommitStart ) * 1000
139        );
140
141        // TODO the below should be an ImportOperation
142        $miscActionsStart = microtime( true );
143        $page = $this->getPageFromImportPlan( $importPlan );
144        $this->createPostImportNullRevision( $importPlan, $user );
145        $this->createPostImportEdit( $importPlan, $page, $user );
146        $this->stats->timing(
147            'FileImporter.import.timing.miscActions',
148            ( microtime( true ) - $miscActionsStart ) * 1000
149        );
150
151        // TODO do we need to call WikiImporter::finishImportPage??
152        // TODO factor logic in WikiImporter::finishImportPage out so we can call it
153
154        $this->stats->timing(
155            'FileImporter.import.timing.wholeImport',
156            ( microtime( true ) - $importStart ) * 1000
157        );
158    }
159
160    /**
161     * @return ImportOperations
162     */
163    private function buildImportOperations(
164        User $user,
165        Title $plannedTitle,
166        ImportDetails $importDetails
167    ) {
168        $textRevisions = $importDetails->getTextRevisions()->toArray();
169        $fileRevisions = $importDetails->getFileRevisions()->toArray();
170        $importOperations = new ImportOperations();
171
172        /**
173         * Text revisions should be added first. See T147451.
174         * This ensures that the page entry is created and if something fails it can thus be deleted.
175         */
176        foreach ( $textRevisions as $textRevision ) {
177            $importOperations->add( new TextRevisionFromTextRevision(
178                $plannedTitle,
179                $user,
180                $textRevision,
181                $this->wikiRevisionFactory,
182                $this->oldRevisionImporter,
183                $this->textRevisionValidator,
184                $this->restrictionStore,
185                $this->logger
186            ) );
187        }
188
189        $totalFileSizes = 0;
190        $initialTextRevision = $textRevisions[0] ?? null;
191
192        foreach ( $fileRevisions as $fileRevision ) {
193            $totalFileSizes += $fileRevision->getField( 'size' );
194            $this->stats->gauge(
195                'FileImporter.import.details.individualFileSizes',
196                $fileRevision->getField( 'size' )
197            );
198            $importOperations->add( new FileRevisionFromRemoteUrl(
199                $plannedTitle,
200                $user,
201                $fileRevision,
202                $initialTextRevision,
203                $this->userLookup,
204                $this->httpRequestExecutor,
205                $this->wikiRevisionFactory,
206                $this->uploadBaseFactory,
207                $this->uploadRevisionImporter,
208                $this->restrictionStore,
209                $this->logger
210            ) );
211
212            // only include the initial text revision in the first upload
213            $initialTextRevision = null;
214        }
215
216        $this->stats->gauge( 'FileImporter.import.details.textRevisions', count( $textRevisions ) );
217        $this->stats->gauge( 'FileImporter.import.details.fileRevisions', count( $fileRevisions ) );
218        $this->stats->gauge( 'FileImporter.import.details.totalFileSizes', $totalFileSizes );
219
220        return $importOperations;
221    }
222
223    private function prepareImportOperations( ImportOperations $importOperations ): void {
224        $status = $importOperations->prepare();
225        if ( !$status->isOK() ) {
226            $this->logger->error( __METHOD__ . ' Failed to prepare operations.', [
227                'status' => $status->__toString(),
228            ] );
229            throw new LocalizedImportException( Status::wrap( $status )->getMessage() );
230        }
231    }
232
233    private function validateImportOperations( StatusValue $status, ImportPlan $importPlan ): void {
234        if ( !$status->isGood() ) {
235            /** @var MessageSpecifier[] $newAbuseFilterWarnings */
236            $newAbuseFilterWarnings = [];
237
238            foreach ( $status->getMessages() as $msg ) {
239                if ( !( $msg instanceof IApiMessage ) ) {
240                    // Unexpected errors bubble up and surface in SpecialImportFile::doImport
241                    throw new LocalizedImportException( $msg );
242                }
243
244                $data = $msg->getApiData()['abusefilter'] ?? null;
245                if ( !$data ||
246                    !in_array( 'warn', $data['actions'] ) ||
247                    in_array( 'disallow', $data['actions'] )
248                ) {
249                    throw new LocalizedImportException( $msg );
250                }
251
252                // Skip AbuseFilter warnings we have seen before
253                if ( !in_array( $data['id'], $importPlan->getValidationWarnings() ) ) {
254                    // @phan-suppress-next-line PhanTypeMismatchArgument
255                    $importPlan->addValidationWarning( $data['id'] );
256                    $newAbuseFilterWarnings[] = $msg;
257                }
258            }
259
260            if ( $newAbuseFilterWarnings ) {
261                throw new AbuseFilterWarningsException( $newAbuseFilterWarnings );
262            }
263        }
264    }
265
266    private function commitImportOperations( ImportOperations $importOperations ): void {
267        $status = $importOperations->commit();
268        if ( !$status->isOK() ) {
269            $this->logger->error( __METHOD__ . ' Failed to commit operations.', [
270                'status' => $status->__toString(),
271            ] );
272            throw new LocalizedImportException( Status::wrap( $status )->getMessage() );
273        }
274    }
275
276    /**
277     * @return StatusValue isOK on success
278     */
279    private function validateFileInfoText(
280        User $user,
281        ImportPlan $importPlan
282    ): StatusValue {
283        $status = $this->textRevisionValidator->validate(
284            $importPlan->getTitle(),
285            $user,
286            new WikitextContent( $importPlan->getFileInfoText() ),
287            $importPlan->getRequest()->getIntendedSummary() ?? '',
288            false
289        );
290        return $status;
291    }
292
293    /**
294     * @return WikiPage
295     * @throws ImportException
296     */
297    private function getPageFromImportPlan( ImportPlan $importPlan ) {
298        // T164729: READ_LATEST needed to select for a write
299        $articleIdForUpdate = $importPlan->getTitle()->getArticleID( IDBAccessObject::READ_LATEST );
300        // T181391: Read from primary database, as the page has only just been created, and in multi-DB setups
301        // replicas will have lag.
302        $page = $this->wikiPageFactory->newFromId( $articleIdForUpdate, IDBAccessObject::READ_LATEST );
303
304        if ( !$page ) {
305            throw new ImportException(
306                'Failed to create import edit with page id: ' . $articleIdForUpdate,
307                self::ERROR_NO_NEW_PAGE );
308        }
309
310        return $page;
311    }
312
313    private function createPostImportNullRevision(
314        ImportPlan $importPlan,
315        UserIdentity $user
316    ): void {
317        $config = MediaWikiServices::getInstance()->getMainConfig();
318        $summary = wfMsgReplaceArgs(
319            $config->get( 'FileImporterCommentForPostImportRevision' ),
320            [ $importPlan->getRequest()->getUrl() ]
321        );
322
323        try {
324            $this->nullRevisionCreator->createForLinkTarget(
325                $importPlan->getTitle(),
326                $importPlan->getDetails()->getFileRevisions()->getLatest(),
327                $user,
328                $summary
329            );
330        } catch ( RuntimeException $ex ) {
331            $this->logger->error( __METHOD__ . ' Failed to create import revision.' );
332            throw $ex;
333        }
334    }
335
336    /**
337     * @param ImportPlan $importPlan
338     * @param WikiPage $page
339     * @param Authority $user
340     */
341    private function createPostImportEdit(
342        ImportPlan $importPlan,
343        WikiPage $page,
344        Authority $user
345    ) {
346        // TODO: Replace with $page->newPageUpdater( … )->saveRevision( … )
347        $editResult = $page->doUserEditContent(
348            new WikitextContent( $importPlan->getFileInfoText() ),
349            $user,
350            $importPlan->getRequest()->getIntendedSummary(),
351            EDIT_UPDATE,
352            false,
353            [ 'fileimporter' ]
354        );
355
356        if ( !$editResult->isOK() ) {
357            $this->logger->error( __METHOD__ . ' Failed to create user edit.', [
358                'status' => $editResult->__toString(),
359            ] );
360            throw new LocalizedImportException( Status::wrap( $editResult )->getMessage() );
361        }
362    }
363
364}