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